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