chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
@@ -0,0 +1,85 @@
from e2b.sandbox._git.args import (
build_add_args,
build_branches_args,
build_checkout_branch_args,
build_clone_plan,
build_commit_args,
build_credential_approve_command,
build_create_branch_args,
build_delete_branch_args,
build_git_command,
build_has_upstream_args,
build_pull_args,
build_push_args,
build_remote_add_args,
build_remote_add_shell_command,
build_remote_get_command,
build_remote_get_url_args,
build_remote_set_url_args,
build_reset_args,
build_restore_args,
build_status_args,
shell_escape,
)
from e2b.sandbox._git.auth import (
build_auth_error_message,
build_upstream_error_message,
is_auth_failure,
is_missing_upstream,
strip_credentials,
with_credentials,
)
from e2b.sandbox._git.config import resolve_config_scope
from e2b.sandbox._git.parse import (
derive_repo_dir_from_url,
parse_git_branches,
parse_git_status,
parse_remote_url,
)
from e2b.sandbox._git.types import (
ClonePlan,
GitBranches,
GitFileStatus,
GitResetMode,
GitStatus,
)
__all__ = [
"build_add_args",
"build_auth_error_message",
"build_branches_args",
"build_checkout_branch_args",
"build_clone_plan",
"build_commit_args",
"build_credential_approve_command",
"build_create_branch_args",
"build_delete_branch_args",
"build_git_command",
"build_has_upstream_args",
"build_pull_args",
"build_push_args",
"build_remote_add_args",
"build_remote_add_shell_command",
"build_remote_get_command",
"build_remote_get_url_args",
"build_remote_set_url_args",
"build_reset_args",
"build_restore_args",
"build_status_args",
"build_upstream_error_message",
"derive_repo_dir_from_url",
"is_auth_failure",
"is_missing_upstream",
"parse_git_branches",
"parse_git_status",
"parse_remote_url",
"resolve_config_scope",
"shell_escape",
"strip_credentials",
"with_credentials",
"ClonePlan",
"GitBranches",
"GitFileStatus",
"GitResetMode",
"GitStatus",
]
@@ -0,0 +1,363 @@
from typing import List, Optional
from e2b.exceptions import InvalidArgumentException
from e2b.sandbox._git.auth import strip_credentials, with_credentials
from e2b.sandbox._git.parse import derive_repo_dir_from_url
from e2b.sandbox._git.types import ClonePlan
def shell_escape(value: str) -> str:
"""
Escape a string for safe use in a shell command.
:param value: Value to escape
:return: Shell-escaped string
"""
return "'" + value.replace("'", "'\"'\"'") + "'"
def build_git_command(args: List[str], repo_path: Optional[str] = None) -> str:
"""
Build a shell-safe git command string.
:param args: Git command arguments
:param repo_path: Repository path for `git -C`, if provided
:return: Shell-safe git command
"""
parts = ["git"]
if repo_path:
parts.extend(["-C", repo_path])
parts.extend(args)
return " ".join(shell_escape(part) for part in parts)
def build_push_args(
remote_name: Optional[str],
*,
remote: Optional[str],
branch: Optional[str],
set_upstream: bool,
) -> List[str]:
"""
Build arguments for a git push command.
:param remote_name: Resolved remote name, if any
:param remote: Remote name override
:param branch: Branch name to push
:param set_upstream: Whether to set upstream tracking
:return: List of git push arguments
"""
args = ["push"]
target_remote = remote_name or remote
if set_upstream and target_remote:
args.append("--set-upstream")
if target_remote:
args.append(target_remote)
if branch:
args.append(branch)
return args
def build_pull_args(
remote: Optional[str],
branch: Optional[str],
remote_name: Optional[str] = None,
) -> List[str]:
"""
Build arguments for a git pull command.
:param remote: Remote name override
:param branch: Branch name to pull
:param remote_name: Resolved remote name, if any
:return: List of git pull arguments
"""
args = ["pull"]
target_remote = remote_name or remote
if target_remote:
args.append(target_remote)
if branch:
args.append(branch)
return args
def build_remote_add_args(name: str, url: str, fetch: bool) -> List[str]:
"""
Build arguments for a git remote add command.
:param name: Remote name
:param url: Remote URL
:param fetch: Whether to fetch after adding the remote
:return: List of git remote add arguments
"""
if not name or not url:
raise InvalidArgumentException(
"Both remote name and URL are required to add a git remote."
)
args = ["remote", "add"]
if fetch:
args.append("-f")
args.extend([name, url])
return args
def build_remote_add_shell_command(
args: List[str],
path: str,
name: str,
url: str,
fetch: bool,
) -> str:
"""
Build a shell command that adds or updates a remote and optionally fetches.
:param args: Base git remote add args
:param path: Repository path
:param name: Remote name
:param url: Remote URL
:param fetch: Whether to fetch after adding the remote
:return: Shell command string
"""
add_cmd = build_git_command(args, path)
set_url_cmd = build_git_command(build_remote_set_url_args(name, url), path)
cmd = f"{add_cmd} || {set_url_cmd}"
if fetch:
fetch_cmd = build_git_command(["fetch", name], path)
cmd = f"({cmd}) && {fetch_cmd}"
return cmd
def build_remote_get_url_args(name: str) -> List[str]:
"""
Build arguments for a git remote get-url command.
"""
return ["remote", "get-url", name]
def build_remote_set_url_args(name: str, url: str) -> List[str]:
"""
Build arguments for a git remote set-url command.
"""
return ["remote", "set-url", name, url]
def build_remote_get_command(path: str, name: str) -> str:
"""
Build a shell command that returns the remote URL or empty output.
:param path: Repository path
:param name: Remote name
:return: Shell command string
"""
if not name:
raise InvalidArgumentException("Remote name is required.")
return f"{build_git_command(build_remote_get_url_args(name), path)} || true"
def build_credential_approve_command(
username: str,
password: str,
host: str,
protocol: str,
) -> str:
"""
Build a git credential approve command for the given credentials.
"""
target_host = host.strip() or "github.com"
target_protocol = protocol.strip() or "https"
credential_input = "\n".join(
[
f"protocol={target_protocol}",
f"host={target_host}",
f"username={username}",
f"password={password}",
"",
"",
]
)
return (
f"printf %s {shell_escape(credential_input)} | "
f"{build_git_command(['credential', 'approve'])}"
)
def build_has_upstream_args() -> List[str]:
"""
Build arguments for a git upstream check command.
"""
return ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
def build_status_args() -> List[str]:
"""
Build arguments for a git status command.
"""
return ["status", "--porcelain=1", "-b"]
def build_branches_args() -> List[str]:
"""
Build arguments for a git branch listing command.
"""
return ["branch", "--format=%(refname:short)\t%(HEAD)"]
def build_create_branch_args(branch: str) -> List[str]:
"""
Build arguments for a git checkout -b command.
"""
return ["checkout", "-b", branch]
def build_checkout_branch_args(branch: str) -> List[str]:
"""
Build arguments for a git checkout command.
"""
return ["checkout", branch]
def build_delete_branch_args(branch: str, force: bool) -> List[str]:
"""
Build arguments for a git branch delete command.
"""
return ["branch", "-D" if force else "-d", branch]
def build_add_args(files: Optional[List[str]], all: bool) -> List[str]:
"""
Build arguments for a git add command.
"""
args = ["add"]
if not files:
args.append("-A" if all else ".")
else:
args.append("--")
args.extend(files)
return args
def build_commit_args(
message: str,
author_name: Optional[str],
author_email: Optional[str],
allow_empty: bool,
) -> List[str]:
"""
Build arguments for a git commit command.
"""
args = ["commit", "-m", message]
if allow_empty:
args.append("--allow-empty")
author_args: List[str] = []
if author_name:
author_args.extend(["-c", f"user.name={author_name}"])
if author_email:
author_args.extend(["-c", f"user.email={author_email}"])
if author_args:
args = author_args + args
return args
def build_reset_args(
mode: Optional[str],
target: Optional[str],
paths: Optional[List[str]],
) -> List[str]:
"""
Build arguments for a git reset command.
"""
allowed_modes = ["soft", "mixed", "hard", "merge", "keep"]
if mode and mode not in allowed_modes:
raise InvalidArgumentException(
f"Reset mode must be one of {', '.join(allowed_modes)}."
)
args = ["reset"]
if mode:
args.append(f"--{mode}")
if target:
args.append(target)
if paths:
args.append("--")
args.extend(paths)
return args
def build_restore_args(
paths: List[str],
staged: Optional[bool],
worktree: Optional[bool],
source: Optional[str],
) -> List[str]:
"""
Build arguments for a git restore command.
"""
if not paths:
raise InvalidArgumentException("At least one path is required.")
resolved_staged = staged
resolved_worktree = worktree
if staged is None and worktree is None:
resolved_worktree = True
elif staged is True and worktree is None:
resolved_worktree = False
elif staged is None and worktree is not None:
resolved_staged = False
if resolved_staged is False and resolved_worktree is False:
raise InvalidArgumentException(
"At least one of staged or worktree must be true."
)
args = ["restore"]
if resolved_worktree:
args.append("--worktree")
if resolved_staged:
args.append("--staged")
if source:
args.extend(["--source", source])
args.append("--")
args.extend(paths)
return args
def build_clone_plan(
url: str,
path: Optional[str],
branch: Optional[str],
depth: Optional[int],
auth_username: Optional[str],
auth_password: Optional[str],
dangerously_store_credentials: bool,
) -> ClonePlan:
"""
Build clone arguments and metadata for post-clone credential stripping.
"""
clone_url = (
with_credentials(url, auth_username, auth_password)
if auth_username and auth_password
else url
)
sanitized_url = strip_credentials(clone_url)
should_strip = not dangerously_store_credentials and sanitized_url != clone_url
repo_path = path if not should_strip else path or derive_repo_dir_from_url(url)
if should_strip and not repo_path:
raise InvalidArgumentException(
"A destination path is required when using credentials without storing them."
)
args = ["clone", clone_url]
if branch:
args.extend(["--branch", branch, "--single-branch"])
if depth:
args.extend(["--depth", str(depth)])
if path:
args.append(path)
return ClonePlan(
args=args,
repo_path=repo_path,
sanitized_url=sanitized_url if should_strip else None,
should_strip=should_strip,
)
@@ -0,0 +1,132 @@
from typing import Optional
from urllib.parse import urlparse, urlunparse
from e2b.exceptions import InvalidArgumentException
from e2b.sandbox.commands.command_handle import CommandExitException
def with_credentials(url: str, username: Optional[str], password: Optional[str]) -> str:
"""
Add HTTP(S) credentials to a Git URL.
:param url: Git repository URL
:param username: Username for HTTP(S) authentication
:param password: Password or token for HTTP(S) authentication
:return: URL with embedded credentials
"""
if not username and not password:
return url
if not username or not password:
raise InvalidArgumentException(
"Both username and password are required when using Git credentials."
)
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise InvalidArgumentException(
"Only http(s) Git URLs support username/password credentials."
)
netloc = f"{username}:{password}@{parsed.netloc}"
return urlunparse(parsed._replace(netloc=netloc))
def strip_credentials(url: str) -> str:
"""
Strip HTTP(S) credentials from a Git URL.
:param url: Git repository URL
:return: URL without embedded credentials
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return url
if not parsed.username and not parsed.password:
return url
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunparse(parsed._replace(netloc=host))
def is_auth_failure(err: Exception) -> bool:
"""
Check whether a git command failed due to authentication issues.
:param err: Exception raised by a git command
:return: True when the error matches common authentication failures
"""
if not isinstance(err, CommandExitException):
return False
message = f"{err.stderr}\n{err.stdout}".lower()
auth_snippets = [
"authentication failed",
"terminal prompts disabled",
"could not read username",
"invalid username or password",
"access denied",
"permission denied",
"not authorized",
]
return any(snippet in message for snippet in auth_snippets)
def is_missing_upstream(err: Exception) -> bool:
"""
Check whether a git command failed due to missing upstream tracking.
:param err: Exception raised by a git command
:return: True when the error matches common upstream failures
"""
if not isinstance(err, CommandExitException):
return False
message = f"{err.stderr}\n{err.stdout}".lower()
upstream_snippets = [
"has no upstream branch",
"no upstream branch",
"no upstream configured",
"no tracking information for the current branch",
"no tracking information",
"set the remote as upstream",
"set the upstream branch",
"please specify which branch you want to merge with",
]
return any(snippet in message for snippet in upstream_snippets)
def build_auth_error_message(action: str, missing_password: bool) -> str:
"""
Build a git authentication error message for the given action.
:param action: Git action name
:param missing_password: Whether the password/token is missing
:return: Error message string
"""
if missing_password:
return f"Git {action} requires a password/token for private repositories."
return f"Git {action} requires credentials for private repositories."
def build_upstream_error_message(action: str) -> str:
"""
Build a git upstream tracking error message for the given action.
:param action: Git action name
:return: Error message string
"""
if action == "push":
return (
"Git push failed because no upstream branch is configured. "
"Set upstream once with set_upstream=True (and optional remote/branch), "
"or pass remote and branch explicitly."
)
return (
"Git pull failed because no upstream branch is configured. "
"Pass remote and branch explicitly, or set upstream once (push with "
"set_upstream=True or run: git branch --set-upstream-to=origin/<branch> <branch>)."
)
@@ -0,0 +1,32 @@
from typing import Optional
from e2b.exceptions import InvalidArgumentException
def resolve_config_scope(
scope: Optional[str], path: Optional[str]
) -> tuple[str, Optional[str]]:
"""
Resolve a git config scope flag and repository path.
:param scope: Requested scope ("global", "local", "system")
:param path: Repository path for local scope
:return: Tuple of (scope flag, repository path)
"""
scope_name = (scope or "global").strip().lower()
if scope_name not in {"global", "local", "system"}:
raise InvalidArgumentException(
"Git config scope must be one of: global, local, system."
)
if scope_name == "local":
if not path:
raise InvalidArgumentException(
"Repository path is required when scope is local."
)
return "--local", path
if scope_name == "system":
return "--system", None
return "--global", None
@@ -0,0 +1,222 @@
from typing import List, Optional
from urllib.parse import urlparse
from e2b.exceptions import InvalidArgumentException
from e2b.sandbox._git.types import GitBranches, GitFileStatus, GitStatus
def derive_repo_dir_from_url(url: str) -> Optional[str]:
"""
Derive the default repository directory name from a Git URL.
:param url: Git repository URL
:return: Repository directory name, if it can be determined
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None
trimmed_path = parsed.path.rstrip("/")
if not trimmed_path:
return None
last_segment = trimmed_path.split("/")[-1]
if not last_segment:
return None
return last_segment[:-4] if last_segment.endswith(".git") else last_segment
def _parse_ahead_behind(segment: Optional[str]) -> tuple[int, int]:
"""
Parse the ahead/behind segment from porcelain branch info.
:param segment: Segment text like "ahead 2, behind 1"
:return: Tuple of (ahead, behind)
"""
if not segment:
return 0, 0
ahead = 0
behind = 0
if "ahead" in segment:
try:
ahead = int(segment.split("ahead")[1].split(",")[0].strip())
except Exception:
ahead = 0
if "behind" in segment:
try:
behind = int(segment.split("behind")[1].split(",")[0].strip())
except Exception:
behind = 0
return ahead, behind
def _normalize_branch_name(name: str) -> str:
"""
Normalize branch names from porcelain branch output.
:param name: Raw branch name section
:return: Normalized branch name
"""
if name.startswith("HEAD (detached at "):
return name.replace("HEAD (detached at ", "").rstrip(")")
return (
name.replace("HEAD (no branch)", "HEAD")
.replace("No commits yet on ", "")
.replace("Initial commit on ", "")
)
def _derive_status(index_status: str, working_status: str) -> str:
"""
Derive a normalized status label from porcelain status characters.
:param index_status: Index status character
:param working_status: Working tree status character
:return: Normalized status label
"""
statuses = {index_status, working_status}
if "U" in statuses:
return "conflict"
if "R" in statuses:
return "renamed"
if "C" in statuses:
return "copied"
if "D" in statuses:
return "deleted"
if "A" in statuses:
return "added"
if "M" in statuses:
return "modified"
if "T" in statuses:
return "typechange"
if "?" in statuses:
return "untracked"
return "unknown"
def parse_git_status(output: str) -> GitStatus:
"""
Parse `git status --porcelain=1 -b` output into a structured object.
:param output: Git status output
:return: Parsed `GitStatus`
"""
lines = [line.rstrip() for line in output.split("\n") if line.strip()]
current_branch: Optional[str] = None
upstream: Optional[str] = None
ahead = 0
behind = 0
detached = False
file_status: List[GitFileStatus] = []
if not lines:
return GitStatus(
current_branch=current_branch,
upstream=upstream,
ahead=ahead,
behind=behind,
detached=detached,
file_status=file_status,
)
branch_line = lines[0]
if branch_line.startswith("## "):
branch_info = branch_line[3:]
ahead_start = branch_info.find(" [")
branch_part = branch_info if ahead_start == -1 else branch_info[:ahead_start]
ahead_part = None if ahead_start == -1 else branch_info[ahead_start + 2 : -1]
normalized_branch = _normalize_branch_name(branch_part)
raw_branch = branch_part
is_detached = raw_branch.startswith("HEAD (detached at ") or (
"detached" in raw_branch
)
if is_detached or normalized_branch.startswith("HEAD"):
detached = True
elif "..." in normalized_branch:
branch, upstream_branch = normalized_branch.split("...")
current_branch = branch or None
upstream = upstream_branch or None
else:
current_branch = normalized_branch or None
ahead, behind = _parse_ahead_behind(ahead_part)
for line in lines[1:]:
if line.startswith("?? "):
name = line[3:]
file_status.append(
GitFileStatus(
name=name,
status="untracked",
index_status="?",
working_tree_status="?",
staged=False,
)
)
continue
if len(line) < 3:
continue
index_status = line[0]
working_status = line[1]
path = line[3:]
renamed_from: Optional[str] = None
name = path
if " -> " in path:
renamed_from, name = path.split(" -> ", 1)
file_status.append(
GitFileStatus(
name=name,
status=_derive_status(index_status, working_status),
index_status=index_status,
working_tree_status=working_status,
staged=index_status not in (" ", "?"),
renamed_from=renamed_from,
)
)
return GitStatus(
current_branch=current_branch,
upstream=upstream,
ahead=ahead,
behind=behind,
detached=detached,
file_status=file_status,
)
def parse_git_branches(output: str) -> GitBranches:
"""
Parse `git branch --format=%(refname:short)\t%(HEAD)` output.
:param output: Git branch output
:return: Parsed `GitBranches`
"""
branches: List[str] = []
current_branch: Optional[str] = None
lines = [line.strip() for line in output.split("\n") if line.strip()]
for line in lines:
parts = line.split("\t")
name = parts[0]
branches.append(name)
if len(parts) > 1 and parts[1] == "*":
current_branch = name
return GitBranches(branches=branches, current_branch=current_branch)
def parse_remote_url(output: str, remote: str) -> str:
"""
Parse a git remote URL output and validate it's present.
:param output: Git remote get-url output
:param remote: Remote name for the error message
:return: Remote URL
"""
url = output.strip()
if not url:
raise InvalidArgumentException(
f'Remote "{remote}" URL not found in repository.'
)
return url
@@ -0,0 +1,149 @@
from dataclasses import dataclass
from typing import List, Optional
from typing_extensions import Literal
GitResetMode = Literal["soft", "mixed", "hard", "merge", "keep"]
"""Mode for a git reset operation."""
@dataclass
class GitFileStatus:
"""
Parsed git status entry for a file.
:param name: Path relative to the repository root
:param status: Normalized status string (e.g. "modified", "added")
:param index_status: Index status character from porcelain output
:param working_tree_status: Working tree status character from porcelain output
:param staged: Whether the change is staged
:param renamed_from: Original path when the file was renamed
"""
name: str
status: str
index_status: str
working_tree_status: str
staged: bool
renamed_from: Optional[str] = None
@dataclass
class GitStatus:
"""
Parsed git repository status.
:param current_branch: Current branch name, if available
:param upstream: Upstream branch name, if available
:param ahead: Number of commits the branch is ahead of upstream
:param behind: Number of commits the branch is behind upstream
:param detached: Whether HEAD is detached
:param file_status: List of file status entries
"""
current_branch: Optional[str]
upstream: Optional[str]
ahead: int
behind: int
detached: bool
file_status: List[GitFileStatus]
@property
def is_clean(self) -> bool:
"""
Return True when there are no tracked or untracked file changes.
"""
return len(self.file_status) == 0
@property
def has_changes(self) -> bool:
"""
Return True when there are any tracked or untracked file changes.
"""
return len(self.file_status) > 0
@property
def has_staged(self) -> bool:
"""
Return True when at least one file has staged changes.
"""
return any(item.staged for item in self.file_status)
@property
def has_untracked(self) -> bool:
"""
Return True when at least one file is untracked.
"""
return any(item.status == "untracked" for item in self.file_status)
@property
def has_conflicts(self) -> bool:
"""
Return True when at least one file is in conflict.
"""
return any(item.status == "conflict" for item in self.file_status)
@property
def total_count(self) -> int:
"""
Return the total number of changed files.
"""
return len(self.file_status)
@property
def staged_count(self) -> int:
"""
Return the number of files with staged changes.
"""
return sum(1 for item in self.file_status if item.staged)
@property
def unstaged_count(self) -> int:
"""
Return the number of files with unstaged changes.
"""
return sum(1 for item in self.file_status if not item.staged)
@property
def untracked_count(self) -> int:
"""
Return the number of untracked files.
"""
return sum(1 for item in self.file_status if item.status == "untracked")
@property
def conflict_count(self) -> int:
"""
Return the number of files with merge conflicts.
"""
return sum(1 for item in self.file_status if item.status == "conflict")
@dataclass
class GitBranches:
"""
Parsed git branch list.
:param branches: List of branch names
:param current_branch: Current branch name, if available
"""
branches: List[str]
current_branch: Optional[str]
@dataclass
class ClonePlan:
"""
Prepared arguments and metadata for git clone.
:param args: Command arguments for git clone
:param repo_path: Repository path to use for post-clone adjustments
:param sanitized_url: Credential-stripped URL to restore
:param should_strip: Whether to reset the remote URL after clone
"""
args: List[str]
repo_path: Optional[str]
sanitized_url: Optional[str]
should_strip: bool
@@ -0,0 +1,69 @@
from dataclasses import dataclass
from typing import Optional
from e2b.exceptions import SandboxException
Stdout = str
"""
Command stdout output.
"""
Stderr = str
"""
Command stderr output.
"""
PtyOutput = bytes
"""
Pty output.
"""
@dataclass
class PtySize:
"""
Pseudo-terminal size.
"""
rows: int
"""
Number of rows.
"""
cols: int
"""
Number of columns.
"""
@dataclass
class CommandResult:
"""
Command execution result.
"""
stderr: str
"""
Command stderr output.
"""
stdout: str
"""
Command stdout output.
"""
exit_code: int
"""
Command exit code.
`0` if the command finished successfully.
"""
error: Optional[str]
"""
Error message from command execution if it failed.
"""
@dataclass
class CommandExitException(SandboxException, CommandResult):
"""
Exception raised when a command exits with a non-zero exit code.
"""
def __str__(self):
return f"Command exited with code {self.exit_code} and error:\n{self.stderr}"
@@ -0,0 +1,39 @@
from dataclasses import dataclass
from typing import Dict, List, Optional
@dataclass
class ProcessInfo:
"""
Information about a command, PTY session or start command running in the sandbox as process.
"""
pid: int
"""
Process ID.
"""
tag: Optional[str]
"""
Custom tag used for identifying special commands like start command in the custom template.
"""
cmd: str
"""
Command that was executed.
"""
args: List[str]
"""
Command arguments.
"""
envs: Dict[str, str]
"""
Environment variables used for the command.
"""
cwd: Optional[str]
"""
Executed command working directory.
"""
@@ -0,0 +1,337 @@
import gzip
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from io import IOBase, TextIOBase
from typing import IO, AsyncIterator, Dict, Iterator, Optional, Union, TypedDict
import httpx
from e2b.envd.filesystem import filesystem_pb2
from e2b.exceptions import InvalidArgumentException
from e2b.io_utils import agzip_iter, aiter_io_chunks, gzip_iter, iter_io_chunks
class FileType(Enum):
"""
Enum representing the type of filesystem object.
"""
FILE = "file"
"""
Filesystem object is a file.
"""
DIR = "dir"
"""
Filesystem object is a directory.
"""
def map_file_type(ft: filesystem_pb2.FileType):
if ft == filesystem_pb2.FileType.FILE_TYPE_FILE:
return FileType.FILE
elif ft == filesystem_pb2.FileType.FILE_TYPE_DIRECTORY:
return FileType.DIR
def map_file_type_str(value: Optional[str]) -> Optional[FileType]:
"""Map a `/files` API type string to `FileType`, `None` when unknown."""
if value == FileType.FILE.value:
return FileType.FILE
elif value == FileType.DIR.value:
return FileType.DIR
return None
@dataclass
class WriteInfo:
"""
Sandbox filesystem object information.
"""
name: str
"""
Name of the filesystem object.
"""
type: Optional[FileType]
"""
Type of the filesystem object.
"""
path: str
"""
Path to the filesystem object.
"""
metadata: Optional[Dict[str, str]] = field(default=None, kw_only=True)
"""
User-defined metadata stored on the file as `user.e2b.*` extended
attributes. On writes this reflects the metadata supplied on upload; on
reads (`get_info`, `list`, `rename`) it reflects any `user.e2b.*` xattr on
the file, including ones set out-of-band. `None` when none is set.
"""
@classmethod
def from_dict(cls, payload: Dict) -> "WriteInfo":
"""Build a `WriteInfo` from a `/files` upload response entry."""
return cls(
name=payload["name"],
type=map_file_type_str(payload.get("type")),
path=payload["path"],
metadata=map_metadata(payload.get("metadata")),
)
@dataclass
class EntryInfo(WriteInfo):
"""
Extended sandbox filesystem object information.
"""
size: int
"""
Size of the filesystem object in bytes.
"""
mode: int
"""
File mode and permission bits.
"""
permissions: str
"""
String representation of file permissions (e.g. 'rwxr-xr-x').
"""
owner: str
"""
Owner of the filesystem object.
"""
group: str
"""
Group owner of the filesystem object.
"""
modified_time: datetime
"""
Last modification time of the filesystem object.
"""
symlink_target: Optional[str] = None
"""
Target of the symlink if the filesystem object is a symlink.
If the filesystem object is not a symlink, this field is None.
"""
def map_entry_info(entry: filesystem_pb2.EntryInfo) -> EntryInfo:
return EntryInfo(
name=entry.name,
type=map_file_type(entry.type),
path=entry.path,
size=entry.size,
mode=entry.mode,
permissions=entry.permissions,
owner=entry.owner,
group=entry.group,
modified_time=entry.modified_time.ToDatetime(tzinfo=timezone.utc),
# Optional, we can't directly access symlink_target otherwise it will be "" instead of None
symlink_target=(
entry.symlink_target if entry.HasField("symlink_target") else None
),
metadata=map_metadata(entry.metadata),
)
class WriteEntry(TypedDict):
"""
Contains path and data of the file to be written to the filesystem.
"""
path: str
data: Union[str, bytes, IO]
class FileStreamReader(Iterator[bytes]):
"""Iterator over a streamed file download.
Returned by ``Sandbox.files.read(format="stream")``. It owns the underlying
HTTP response and releases its pooled connection as soon as the stream is
fully consumed, an error is raised while reading (including the idle-read
timeout, which raises ``httpx.ReadTimeout``), or the reader is closed.
There is no garbage-collection safety net, so always consume it fully, use
it as a context manager, or call :meth:`close`::
with sandbox.files.read(path, format="stream") as stream:
for chunk in stream:
...
"""
def __init__(self, response: httpx.Response):
self._response = response
self._iterator = response.iter_bytes()
self._closed = False
def __iter__(self) -> Iterator[bytes]:
return self
def __next__(self) -> bytes:
try:
return next(self._iterator)
except BaseException:
# Covers normal end (StopIteration) and read errors alike.
self.close()
raise
def close(self) -> None:
"""Release the underlying HTTP connection. Safe to call multiple times."""
if self._closed:
return
self._closed = True
self._response.close()
def __enter__(self) -> "FileStreamReader":
return self
def __exit__(self, *exc_info) -> None:
self.close()
class AsyncFileStreamReader(AsyncIterator[bytes]):
"""Async iterator over a streamed file download.
Returned by ``AsyncSandbox.files.read(format="stream")``. It owns the
underlying HTTP response and releases its pooled connection as soon as the
stream is fully consumed, an error is raised while reading (including the
idle-read timeout, which raises ``httpx.ReadTimeout``), or the reader is
closed.
There is no garbage-collection safety net (releasing an async connection
requires awaiting ``aclose()``, which a finalizer cannot do reliably), so
always consume it fully, use it as an async context manager, or call
:meth:`aclose`::
async with await sandbox.files.read(path, format="stream") as stream:
async for chunk in stream:
...
"""
def __init__(self, response: httpx.Response):
self._response = response
self._iterator = response.aiter_bytes()
self._closed = False
def __aiter__(self) -> AsyncIterator[bytes]:
return self
async def __anext__(self) -> bytes:
try:
return await self._iterator.__anext__()
except BaseException:
# Covers normal end (StopAsyncIteration) and read errors alike.
await self.aclose()
raise
async def aclose(self) -> None:
"""Release the underlying HTTP connection. Safe to call multiple times."""
if self._closed:
return
self._closed = True
await self._response.aclose()
async def __aenter__(self) -> "AsyncFileStreamReader":
return self
async def __aexit__(self, *exc_info) -> None:
await self.aclose()
def _to_httpx_file(file_path: str, file_data: Union[str, bytes, IO]):
"""Build an httpx multipart `("file", (name, data))` tuple for the upload."""
if isinstance(file_data, (str, bytes)):
return ("file", (file_path, file_data))
elif isinstance(file_data, TextIOBase):
return ("file", (file_path, file_data.read()))
elif isinstance(file_data, IOBase):
return ("file", (file_path, file_data))
else:
raise InvalidArgumentException(f"Unsupported data type for file {file_path}")
def to_upload_body(
data: Union[str, bytes, IO],
use_gzip: bool = False,
) -> Union[bytes, IO, Iterator[bytes]]:
"""Prepare file data for upload, optionally gzip-compressed.
File-like objects are streamed in chunks instead of being buffered in
memory.
"""
if isinstance(data, (str, bytes)):
raw = data.encode("utf-8") if isinstance(data, str) else data
return gzip.compress(raw) if use_gzip else raw
elif isinstance(data, (TextIOBase, IOBase)):
if use_gzip:
return gzip_iter(iter_io_chunks(data))
if isinstance(data, TextIOBase):
# Text-mode IO yields str chunks—encode them while streaming.
return iter_io_chunks(data)
# httpx streams binary file-like objects in chunks without buffering.
return data
else:
raise InvalidArgumentException(f"Unsupported data type: {type(data)}")
def to_upload_body_async(
data: Union[str, bytes, IO],
use_gzip: bool = False,
) -> Union[bytes, AsyncIterator[bytes]]:
"""Prepare file data for upload with async httpx, optionally gzip-compressed.
File-like objects are streamed in chunks instead of being buffered in
memory. Async httpx requires an async iterable for streamed request bodies.
"""
if isinstance(data, (str, bytes)):
raw = data.encode("utf-8") if isinstance(data, str) else data
return gzip.compress(raw) if use_gzip else raw
elif isinstance(data, (TextIOBase, IOBase)):
chunks = aiter_io_chunks(data)
return agzip_iter(chunks) if use_gzip else chunks
else:
raise InvalidArgumentException(f"Unsupported data type: {type(data)}")
METADATA_HEADER_PREFIX = "X-Metadata-"
# Metadata keys travel as `X-Metadata-<key>` HTTP header names, so they must be
# valid header tokens (RFC 7230); values travel as header values, restricted to
# printable US-ASCII.
_METADATA_KEY_REGEX = re.compile(r"\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\Z")
_METADATA_VALUE_REGEX = re.compile(r"\A[\x20-\x7e]*\Z")
def validate_metadata(metadata: Optional[Dict[str, str]]) -> None:
"""Validate metadata keys/values before they are sent as upload headers."""
if not metadata:
return
for key, value in metadata.items():
if not _METADATA_KEY_REGEX.match(key):
raise InvalidArgumentException(
f"Invalid metadata key {key!r}: keys must be non-empty and use only "
"HTTP token characters (letters, digits and !#$%&'*+-.^_`|~)."
)
if not _METADATA_VALUE_REGEX.match(value):
raise InvalidArgumentException(
f"Invalid metadata value for key {key!r}: values must be printable US-ASCII."
)
def metadata_to_headers(
metadata: Optional[Dict[str, str]],
) -> Dict[str, str]:
"""Translate user metadata into the `X-Metadata-*` upload headers envd reads."""
if not metadata:
return {}
return {f"{METADATA_HEADER_PREFIX}{key}": value for key, value in metadata.items()}
def map_metadata(metadata) -> Optional[Dict[str, str]]:
"""Normalize a proto/HTTP metadata map: drop empties and return a plain dict or None."""
if not metadata:
return None
return dict(metadata)
@@ -0,0 +1,70 @@
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from e2b.envd.filesystem.filesystem_pb2 import EventType
from e2b.sandbox.filesystem.filesystem import EntryInfo
class FilesystemEventType(Enum):
"""
Enum representing the type of filesystem event.
"""
CHMOD = "chmod"
"""
Filesystem object permissions were changed.
"""
CREATE = "create"
"""
Filesystem object was created.
"""
REMOVE = "remove"
"""
Filesystem object was removed.
"""
RENAME = "rename"
"""
Filesystem object was renamed.
"""
WRITE = "write"
"""
Filesystem object was written to.
"""
def map_event_type(event: EventType):
if event == EventType.EVENT_TYPE_CHMOD:
return FilesystemEventType.CHMOD
elif event == EventType.EVENT_TYPE_CREATE:
return FilesystemEventType.CREATE
elif event == EventType.EVENT_TYPE_REMOVE:
return FilesystemEventType.REMOVE
elif event == EventType.EVENT_TYPE_RENAME:
return FilesystemEventType.RENAME
elif event == EventType.EVENT_TYPE_WRITE:
return FilesystemEventType.WRITE
@dataclass
class FilesystemEvent:
"""
Contains information about the filesystem event - the name of the file and the type of the event.
"""
name: str
"""
Relative path to the filesystem object.
"""
type: FilesystemEventType
"""
Filesystem operation event type.
"""
entry: Optional[EntryInfo] = None
"""
Information about the entry that triggered the event.
Only populated when the watch was started with `include_entry=True` and the
sandbox's envd version supports it. It may be `None` for events where the entry
no longer exists at the path (e.g. remove or rename-away events).
"""
+227
View File
@@ -0,0 +1,227 @@
import urllib.parse
from typing import Optional, TypedDict
from packaging.version import Version
from e2b.connection_config import ConnectionConfig, default_username
from e2b.envd.api import ENVD_API_FILES_ROUTE
from e2b.envd.versions import ENVD_DEFAULT_USER
from e2b.exceptions import InvalidArgumentException
from e2b.sandbox.signature import get_signature
class SandboxOpts(TypedDict):
sandbox_id: str
sandbox_domain: Optional[str]
envd_version: Version
envd_access_token: Optional[str]
traffic_access_token: Optional[str]
connection_config: ConnectionConfig
class SandboxBase:
mcp_port = 50005
default_sandbox_timeout = 300
default_template = "base"
default_mcp_template = "mcp-gateway"
def __init__(
self,
sandbox_id: str,
envd_version: Version,
envd_access_token: Optional[str],
sandbox_domain: Optional[str],
connection_config: ConnectionConfig,
traffic_access_token: Optional[str] = None,
):
self.__connection_config = connection_config
self.__sandbox_id = sandbox_id
self.__sandbox_domain = sandbox_domain or self.connection_config.domain
self.__envd_version = envd_version
self.__envd_access_token = envd_access_token
self.__traffic_access_token = traffic_access_token
self.__envd_api_url = self.connection_config.get_sandbox_url(
self.sandbox_id, self.sandbox_domain
)
self.__envd_direct_url = self.connection_config.get_sandbox_direct_url(
self.sandbox_id, self.sandbox_domain
)
self.__mcp_token: Optional[str] = None
@property
def _envd_access_token(self) -> Optional[str]:
"""Private property to access the envd token"""
return self.__envd_access_token
@property
def _mcp_token(self) -> Optional[str]:
return self.__mcp_token
@_mcp_token.setter
def _mcp_token(self, token: str) -> None:
self.__mcp_token = token
@property
def connection_config(self) -> ConnectionConfig:
return self.__connection_config
@property
def _envd_version(self) -> Version:
return self.__envd_version
@property
def traffic_access_token(self) -> Optional[str]:
return self.__traffic_access_token
@property
def sandbox_domain(self) -> str:
return self.__sandbox_domain
@property
def envd_api_url(self) -> str:
return self.__envd_api_url
@property
def envd_direct_url(self) -> str:
return self.__envd_direct_url
@property
def sandbox_id(self) -> str:
"""
Unique identifier of the sandbox.
"""
return self.__sandbox_id
def _file_url(
self,
path: str,
user: Optional[str] = None,
signature: Optional[str] = None,
signature_expiration: Optional[int] = None,
) -> str:
url = urllib.parse.urljoin(self.envd_direct_url, ENVD_API_FILES_ROUTE)
query = {"path": path} if path else {}
if user:
query["username"] = user
if signature:
query["signature"] = signature
if signature_expiration:
if signature is None:
raise ValueError("signature_expiration requires signature to be set")
query["signature_expiration"] = str(signature_expiration)
params = urllib.parse.urlencode(
query,
quote_via=urllib.parse.quote,
)
url = urllib.parse.urljoin(url, f"?{params}")
return url
def download_url(
self,
path: str,
user: Optional[str] = None,
use_signature_expiration: Optional[int] = None,
) -> str:
"""
Get the URL to download a file from the sandbox.
:param path: Path to the file to download
:param user: User to download the file as
:param use_signature_expiration: Expiration time for the signed URL in seconds
:return: URL for downloading file
"""
use_signature = self._envd_access_token is not None
if not use_signature and use_signature_expiration is not None:
raise InvalidArgumentException(
"Signature expiration can be used only when sandbox is created as secured."
)
username = user
if username is None and self._envd_version < ENVD_DEFAULT_USER:
username = default_username
if use_signature:
signature = get_signature(
path,
"read",
username,
self._envd_access_token,
use_signature_expiration,
)
return self._file_url(
path, username, signature["signature"], signature["expiration"]
)
else:
return self._file_url(path, username)
def upload_url(
self,
path: str,
user: Optional[str] = None,
use_signature_expiration: Optional[int] = None,
) -> str:
"""
Get the URL to upload a file to the sandbox.
You have to send a POST request to this URL with the file as multipart/form-data.
:param path: Path to the file to upload
:param user: User to upload the file as
:param use_signature_expiration: Expiration time for the signed URL in seconds
:return: URL for uploading file
"""
use_signature = self._envd_access_token is not None
if not use_signature and use_signature_expiration is not None:
raise InvalidArgumentException(
"Signature expiration can be used only when sandbox is created as secured."
)
username = user
if username is None and self._envd_version < ENVD_DEFAULT_USER:
username = default_username
if use_signature:
signature = get_signature(
path,
"write",
username,
self._envd_access_token,
use_signature_expiration,
)
return self._file_url(
path, username, signature["signature"], signature["expiration"]
)
else:
return self._file_url(path, username)
def get_host(self, port: int) -> str:
"""
Get the host address to connect to the sandbox.
You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket.
:param port: Port to connect to
:return: Host address to connect to
"""
return self.connection_config.get_host(
self.sandbox_id, self.sandbox_domain, port
)
def get_mcp_url(self) -> str:
"""
Get the MCP URL for the sandbox.
:returns MCP URL for the sandbox.
"""
return f"https://{self.get_host(self.mcp_port)}/mcp"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
"""
Network configuration helpers for E2B sandboxes.
"""
"""
CIDR range that represents all traffic.
"""
ALL_TRAFFIC = "0.0.0.0/0"
@@ -0,0 +1,624 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import (
Any,
Callable,
Dict,
List,
Literal,
Mapping,
Optional,
TypedDict,
Union,
cast,
)
from typing_extensions import NotRequired, Unpack
from e2b.api.client.models import (
ListedSandbox,
SandboxDetail,
SandboxState,
)
from e2b.api.client.models import (
SandboxLifecycle as ClientSandboxLifecycle,
)
from e2b.api.client.models import (
SandboxNetworkConfig as ClientSandboxNetworkConfig,
)
from e2b.api.client.models import (
SandboxNetworkConfigRules,
)
from e2b.api.client.models import (
SandboxNetworkRule as ClientSandboxNetworkRule,
)
from e2b.api.client.models import (
SandboxNetworkTransform as ClientSandboxNetworkTransform,
)
from e2b.api.client.models import (
SandboxNetworkTransformHeaders as ClientSandboxNetworkTransformHeaders,
)
from e2b.api.client.models import (
SandboxNetworkUpdateConfig,
)
from e2b.api.client.models import (
SandboxNetworkUpdateConfigRules,
)
from e2b.api.client.types import Unset
from e2b.connection_config import ApiParams
from e2b.sandbox.mcp import McpServer as BaseMcpServer
from e2b.sandbox.network import ALL_TRAFFIC
from e2b.paginator import PaginatorBase
class GitHubMcpServerConfig(TypedDict):
"""
Configuration for a GitHub-based MCP server.
"""
run_cmd: str
"""
Command to run the MCP server. Must start a stdio-compatible server.
"""
install_cmd: NotRequired[str]
"""
Command to install dependencies for the MCP server. Working directory is the root of the github repository.
"""
envs: NotRequired[Dict[str, str]]
"""
Environment variables to set in the MCP process.
"""
# Extended MCP server configuration that includes base servers
# and allows dynamic GitHub-based MCP servers with custom run and install commands.
# For GitHub servers, use keys in the format "github/owner/repo"
GitHubMcpServer = Dict[str, Union[GitHubMcpServerConfig, Any]]
# Union type that combines base MCP servers with GitHub-based servers
McpServer = Union[BaseMcpServer, GitHubMcpServer]
class SandboxNetworkTransform(TypedDict):
"""
Transform applied to egress requests matching a :class:`SandboxNetworkRule`.
"""
headers: NotRequired[Dict[str, str]]
"""
Headers to inject into the outbound request. Values override any headers
already present on the request.
"""
class SandboxNetworkRule(TypedDict):
"""
Per-domain rule applied to egress requests.
"""
transform: NotRequired[SandboxNetworkTransform]
"""
Transform applied to requests matching this rule.
"""
SandboxNetworkRules = Dict[str, List[SandboxNetworkRule]]
"""
Map of host (or CIDR / IP) to ordered list of rules applied to outbound
requests for that host. Registering a host here does not allow egress on its
own — the host must also appear in ``SandboxNetworkOpts.allow_out``.
"""
class SandboxNetworkRuleInfo(TypedDict):
"""
Per-domain rule as returned by the sandbox info endpoint. Mirrors
:class:`SandboxNetworkRule` but with ``transform`` always materialized to
the static :class:`SandboxNetworkTransform` shape — no callable variant.
"""
transform: NotRequired[SandboxNetworkTransform]
@dataclass(frozen=True)
class SandboxNetworkSelectorContext:
"""
Context passed to ``allow_out``/``deny_out`` callables.
"""
all_traffic: str
"""All traffic sentinel — equivalent to ``"0.0.0.0/0"``."""
rules: Mapping[str, List[SandboxNetworkRule]]
"""Rules registered in :attr:`SandboxNetworkOpts.rules`."""
SandboxNetworkSelector = Union[
List[str],
Callable[[SandboxNetworkSelectorContext], List[str]],
]
"""
Egress rule list, either a static list of CIDR blocks / IP addresses /
hostnames, or a callable that receives a :class:`SandboxNetworkSelectorContext`
and returns the same.
"""
class SandboxNetworkOpts(TypedDict):
"""
Sandbox network configuration options.
"""
allow_out: NotRequired[SandboxNetworkSelector]
"""
Allow outbound traffic from the sandbox to the specified addresses.
If ``allow_out`` is not specified, all outbound traffic is allowed.
Accepts either a static list of CIDR blocks / IP addresses / hostnames, or
a callable that receives a :class:`SandboxNetworkSelectorContext` and
returns the same. ``ctx.all_traffic`` is ``"0.0.0.0/0"``; ``ctx.rules`` is
a read-only view of :attr:`rules`.
Examples:
- Static list: ``["1.1.1.1", "8.8.8.0/24"]``
- Allow only rule-registered hosts:
``lambda ctx: list(ctx.rules.keys())``
"""
deny_out: NotRequired[SandboxNetworkSelector]
"""
Deny outbound traffic from the sandbox to the specified addresses.
Accepts the same shapes as ``allow_out``.
Examples:
- Static list: ``["1.1.1.1", "8.8.8.0/24"]``
- Block all egress: ``lambda ctx: [ctx.all_traffic]``
"""
rules: NotRequired[SandboxNetworkRules]
"""
Per-domain transform rules applied to matching egress HTTP/HTTPS
requests. Keys are domains (e.g. ``"api.example.com"``); values are
ordered lists of :class:`SandboxNetworkRule`.
Registering a host here does not allow egress on its own — the host must
also appear in ``allow_out``. Hosts registered here are exposed to the
``allow_out``/``deny_out`` callables via ``ctx.rules``.
"""
allow_public_traffic: NotRequired[bool]
"""
Controls whether sandbox URLs should be publicly accessible or require authentication.
Defaults to True.
"""
mask_request_host: NotRequired[str]
"""
Allows specifying a custom host mask for all sandbox requests.
Supports ${PORT} variable. Defaults to "${PORT}-sandboxid.e2b.app".
Examples:
- Custom subdomain: `"${PORT}-myapp.example.com"`
"""
class SandboxNetworkUpdate(TypedDict, total=False):
"""
Subset of :class:`SandboxNetworkOpts` accepted by ``Sandbox.update_network``.
The update endpoint replaces all egress rules atomically — fields that are
omitted are cleared on the server.
"""
allow_out: SandboxNetworkSelector
"""See :attr:`SandboxNetworkOpts.allow_out`."""
deny_out: SandboxNetworkSelector
"""See :attr:`SandboxNetworkOpts.deny_out`."""
rules: SandboxNetworkRules
"""See :attr:`SandboxNetworkOpts.rules`."""
allow_internet_access: bool
"""
Allow sandbox to access the internet. When set to ``False``, it behaves the
same as specifying ``deny_out=["0.0.0.0/0"]`` in the network config.
"""
class SandboxNetworkInfo(TypedDict, total=False):
"""
Network configuration as returned by the sandbox info endpoint.
Mirrors :class:`SandboxNetworkOpts` but with ``allow_out``/``deny_out``
always materialized to plain string lists.
"""
allow_out: List[str]
deny_out: List[str]
rules: Dict[str, List[SandboxNetworkRuleInfo]]
allow_public_traffic: bool
mask_request_host: str
class SandboxOnTimeoutPause(TypedDict):
"""
Object form of `on_timeout` that auto-pauses the sandbox when the timeout is
reached, optionally controlling the pause snapshot kind via `keep_memory`.
"""
action: Literal["pause"]
"""Auto-pause the sandbox when the timeout is reached."""
keep_memory: NotRequired[bool]
"""
Whether the timeout auto-pause keeps a full memory snapshot. Defaults to `True`.
When `False`, the auto-pause drops the in-memory state and persists only the
filesystem (a filesystem-only snapshot); resuming such a sandbox cold-boots
(reboots) it from disk, losing running processes and open connections.
Cannot be combined with `auto_resume`: auto-resume wakes a paused sandbox on
inbound traffic by restoring its memory snapshot in place, so the request that
woke it hits an already-running process. A filesystem-only snapshot has no
memory to restore — resuming cold-boots it — so it can't be woken transparently
by traffic and must be resumed explicitly via `connect()`.
"""
class SandboxOnTimeoutKill(TypedDict):
"""
Object form of `on_timeout` that kills the sandbox when the timeout is reached.
"""
action: Literal["kill"]
"""Kill the sandbox when the timeout is reached."""
SandboxOnTimeout = Union[
Literal["pause", "kill"], SandboxOnTimeoutPause, SandboxOnTimeoutKill
]
"""
What should happen to the sandbox when the timeout is reached. Either the bare
action (`"pause"` / `"kill"`) or the object form. The object form is a
discriminated union on `action`: `keep_memory` is only accepted alongside
`action: "pause"`. Passing `keep_memory` with `action: "kill"` is a static type
error.
"""
class SandboxLifecycle(TypedDict):
"""
Sandbox lifecycle configuration; defines post-timeout behavior and auto-resume settings.
Defaults to `on_timeout="kill"` and `auto_resume=False`.
"""
on_timeout: SandboxOnTimeout
"""
What should happen to the sandbox when timeout is reached. `"kill"` terminates
the sandbox; `"pause"` pauses it for later resume. Accepts either the bare
action or an object `{"action": "pause", "keep_memory": ...}` /
`{"action": "kill"}` to also control the pause snapshot kind. Defaults to
`"kill"`.
"""
auto_resume: NotRequired[bool]
"""
Whether activity should cause the sandbox to resume when paused. Defaults to `False`.
Can be `True` only when `on_timeout` is `pause`. Not supported when
`keep_memory` is `False` (a filesystem-only snapshot must be resumed
explicitly via `connect()`).
"""
class SandboxInfoLifecycle(TypedDict):
"""
Sandbox lifecycle configuration returned by sandbox info.
"""
on_timeout: Literal["pause", "kill"]
"""
What should happen to the sandbox when timeout is reached.
"""
auto_resume: bool
"""
Whether activity should cause the sandbox to resume when paused.
"""
def _resolve_network_selector(
selector: Optional[SandboxNetworkSelector],
rules: Mapping[str, List[SandboxNetworkRule]],
) -> Optional[List[str]]:
if selector is None:
return None
if callable(selector):
ctx = SandboxNetworkSelectorContext(all_traffic=ALL_TRAFFIC, rules=rules)
return list(selector(ctx))
return list(selector)
def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules:
client_rules = SandboxNetworkConfigRules()
for host, host_rules in rules.items():
converted: List[ClientSandboxNetworkRule] = []
for rule in host_rules:
transform = rule.get("transform")
if transform is None:
converted.append(ClientSandboxNetworkRule())
continue
client_transform = ClientSandboxNetworkTransform()
headers = transform.get("headers")
if headers:
client_headers = ClientSandboxNetworkTransformHeaders()
client_headers.additional_properties = dict(headers)
client_transform.headers = client_headers
converted.append(ClientSandboxNetworkRule(transform=client_transform))
client_rules.additional_properties[host] = converted
return client_rules
def _build_network_egress(
network: Mapping[str, Any],
) -> Dict[str, Any]:
"""
Resolve the shared egress fields (``allow_out`` / ``deny_out`` / per-host
``rules``) used by both the create and update endpoints. ``rules`` in the
returned dict is the inner ``Dict[host, List[ClientSandboxNetworkRule]]``
— callers wrap it in their endpoint-specific rules attrs class.
"""
rules = network.get("rules") or {}
allow_out = _resolve_network_selector(network.get("allow_out"), rules)
deny_out = _resolve_network_selector(network.get("deny_out"), rules)
body: Dict[str, Any] = {}
if allow_out is not None:
body["allow_out"] = allow_out
if deny_out is not None:
body["deny_out"] = deny_out
if "rules" in network and network["rules"] is not None:
body["rules"] = _build_client_rules(network["rules"]).additional_properties
return body
def build_network_config(
network: Optional[SandboxNetworkOpts],
) -> Optional[Dict[str, Any]]:
"""Resolve a :class:`SandboxNetworkOpts` into the dict the API expects."""
if network is None:
return None
body = _build_network_egress(network)
if "rules" in body:
client_rules = SandboxNetworkConfigRules()
client_rules.additional_properties = body["rules"]
body["rules"] = client_rules
if "allow_public_traffic" in network:
body["allow_public_traffic"] = network["allow_public_traffic"]
if "mask_request_host" in network:
body["mask_request_host"] = network["mask_request_host"]
return body
def build_network_update_body(
network: SandboxNetworkUpdate,
) -> SandboxNetworkUpdateConfig:
"""Resolve a :class:`SandboxNetworkUpdate` into the API client body."""
egress = _build_network_egress(network)
body = SandboxNetworkUpdateConfig()
if "allow_out" in egress:
body.allow_out = egress["allow_out"]
if "deny_out" in egress:
body.deny_out = egress["deny_out"]
if "rules" in egress:
rules = SandboxNetworkUpdateConfigRules()
rules.additional_properties = egress["rules"]
body.rules = rules
if "allow_internet_access" in network:
body.allow_internet_access = network["allow_internet_access"]
return body
def from_client_network_config(
network: Union[Unset, ClientSandboxNetworkConfig],
) -> Optional[SandboxNetworkInfo]:
if isinstance(network, Unset):
return None
result: SandboxNetworkInfo = {}
if not isinstance(network.allow_out, Unset):
result["allow_out"] = list(network.allow_out)
if not isinstance(network.deny_out, Unset):
result["deny_out"] = list(network.deny_out)
if not isinstance(network.rules, Unset):
result["rules"] = cast(
Dict[str, List[SandboxNetworkRuleInfo]], network.rules.to_dict()
)
if not isinstance(network.allow_public_traffic, Unset):
result["allow_public_traffic"] = network.allow_public_traffic
if not isinstance(network.mask_request_host, Unset):
result["mask_request_host"] = network.mask_request_host
return result
def from_client_lifecycle(
lifecycle: Union[Unset, ClientSandboxLifecycle],
) -> Optional[SandboxInfoLifecycle]:
if isinstance(lifecycle, Unset):
return None
result: SandboxInfoLifecycle = {
"on_timeout": cast(Literal["pause", "kill"], lifecycle.on_timeout),
"auto_resume": lifecycle.auto_resume,
}
return result
@dataclass
class SandboxInfo:
"""Information about a sandbox."""
sandbox_id: str
"""Sandbox ID."""
sandbox_domain: Optional[str]
"""Domain where the sandbox is hosted."""
template_id: str
"""Template ID."""
name: Optional[str]
"""Template name."""
metadata: Dict[str, str]
"""Saved sandbox metadata."""
started_at: datetime
"""Sandbox start time."""
end_at: datetime
"""Sandbox expiration date."""
state: SandboxState
"""Sandbox state."""
cpu_count: int
"""Sandbox CPU count."""
memory_mb: int
"""Sandbox Memory size in MiB."""
envd_version: str
"""Envd version."""
allow_internet_access: Optional[bool] = None
"""Whether internet access was explicitly enabled or disabled for the sandbox."""
network: Optional[SandboxNetworkInfo] = None
"""Sandbox network configuration."""
lifecycle: Optional[SandboxInfoLifecycle] = None
"""Sandbox lifecycle configuration."""
volume_mounts: List[Dict[str, str]] = field(default_factory=list)
"""Volume mounts for the sandbox."""
@classmethod
def _from_sandbox_data(
cls,
sandbox: Union[ListedSandbox, SandboxDetail],
sandbox_domain: Optional[str] = None,
allow_internet_access: Optional[bool] = None,
network: Optional[SandboxNetworkInfo] = None,
lifecycle: Optional[SandboxInfoLifecycle] = None,
):
return cls(
sandbox_domain=sandbox_domain,
sandbox_id=sandbox.sandbox_id,
template_id=sandbox.template_id,
name=(sandbox.alias if isinstance(sandbox.alias, str) else None),
metadata=cast(
Dict[str, str],
sandbox.metadata if isinstance(sandbox.metadata, dict) else {},
),
started_at=sandbox.started_at,
end_at=sandbox.end_at,
state=sandbox.state,
cpu_count=sandbox.cpu_count,
memory_mb=sandbox.memory_mb,
envd_version=sandbox.envd_version,
volume_mounts=[
{"name": vm.name, "path": vm.path} for vm in sandbox.volume_mounts
]
if not isinstance(sandbox.volume_mounts, Unset)
else [],
allow_internet_access=allow_internet_access,
network=network,
lifecycle=lifecycle,
)
@classmethod
def _from_listed_sandbox(cls, listed_sandbox: ListedSandbox):
return cls._from_sandbox_data(listed_sandbox)
@classmethod
def _from_sandbox_detail(cls, sandbox_detail: SandboxDetail):
return cls._from_sandbox_data(
sandbox_detail,
sandbox_domain=(
sandbox_detail.domain
if isinstance(sandbox_detail.domain, str)
else None
),
allow_internet_access=(
sandbox_detail.allow_internet_access
if isinstance(sandbox_detail.allow_internet_access, bool)
else None
),
network=from_client_network_config(sandbox_detail.network),
lifecycle=from_client_lifecycle(sandbox_detail.lifecycle),
)
@dataclass
class SandboxQuery:
"""Query parameters for listing sandboxes."""
metadata: Optional[dict[str, str]] = None
"""Filter sandboxes by metadata."""
state: Optional[list[SandboxState]] = None
"""Filter sandboxes by state."""
@dataclass
class SandboxMetrics:
"""Sandbox metrics."""
cpu_count: int
"""Number of CPUs."""
cpu_used_pct: float
"""CPU usage percentage."""
disk_total: int
"""Total disk space in bytes."""
disk_used: int
"""Disk used in bytes."""
mem_total: int
"""Total memory in bytes."""
mem_used: int
"""Memory used in bytes."""
mem_cache: int
"""Cached memory (page cache) in bytes."""
timestamp: datetime
"""Timestamp of the metric entry."""
@dataclass
class SnapshotInfo:
"""Information about a snapshot."""
snapshot_id: str
"""Snapshot identifier — template ID with tag, or namespaced name with tag (e.g. my-snapshot:latest). Can be used with Sandbox.create() to create a new sandbox from this snapshot."""
names: List[str] = field(default_factory=list)
"""Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2)."""
class SnapshotPaginatorBase(PaginatorBase[SnapshotInfo, ApiParams]):
def __init__(
self,
sandbox_id: Optional[str] = None,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
):
super().__init__(limit=limit, next_token=next_token, **opts)
self.sandbox_id = sandbox_id
class SandboxPaginatorBase(PaginatorBase[SandboxInfo, ApiParams]):
def __init__(
self,
query: Optional[SandboxQuery] = None,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
):
super().__init__(limit=limit, next_token=next_token, **opts)
self.query = query
@@ -0,0 +1,47 @@
import base64
import hashlib
import time
from typing import Optional, TypedDict, Literal
Operation = Literal["read", "write"]
class Signature(TypedDict):
signature: str
expiration: Optional[int] # Unix timestamp or None
def get_signature(
path: str,
operation: Operation,
user: Optional[str],
envd_access_token: Optional[str],
expiration_in_seconds: Optional[int] = None,
) -> Signature:
"""
Generate a v1 signature for sandbox file URLs.
"""
if not envd_access_token:
raise ValueError("Access token is not set and signature cannot be generated!")
expiration = (
int(time.time()) + expiration_in_seconds
if expiration_in_seconds is not None
else None
)
# if user is None, set it to empty string to handle default user
if user is None:
user = ""
raw = (
f"{path}:{operation}:{user}:{envd_access_token}"
if expiration is None
else f"{path}:{operation}:{user}:{envd_access_token}:{expiration}"
)
digest = hashlib.sha256(raw.encode("utf-8")).digest()
encoded = base64.b64encode(digest).rstrip(b"=").decode("ascii")
return {"signature": f"v1_{encoded}", "expiration": expiration}
+34
View File
@@ -0,0 +1,34 @@
from typing import TypeVar, Any, Generic, cast, Optional, Type
import functools
T = TypeVar("T")
class class_method_variant(Generic[T]):
def __init__(self, class_method_name):
self.class_method_name = class_method_name
method: Any
def __call__(self, method: T) -> T:
self.method = method
return cast(T, self)
def __get__(self, obj, objtype: Optional[Type[Any]] = None):
@functools.wraps(self.method)
def _wrapper(*args, **kwargs):
if obj is not None:
# Method was called as an instance method, e.g.
# instance.method(...)
return self.method(obj, *args, **kwargs)
elif len(args) > 0 and objtype is not None and isinstance(args[0], objtype):
# Method was called as a class method with the instance as the
# first argument, e.g. Class.method(instance, ...) which in
# Python is the same thing as calling an instance method
return self.method(args[0], *args[1:], **kwargs)
else:
# Method was called as a class method, e.g. Class.method(...)
class_method = getattr(objtype, self.class_method_name)
return class_method(*args, **kwargs)
return _wrapper