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,45 @@
"""
Special step name for the finalization phase of template building.
This is the last step that runs after all user-defined instructions.
"""
FINALIZE_STEP_NAME = "finalize"
"""
Special step name for the base image phase of template building.
This is the first step that sets up the base image.
"""
BASE_STEP_NAME = "base"
"""
Stack trace depth for capturing caller information.
Depth levels:
1. TemplateClass
2. Caller method (e.g., copy(), from_image(), etc.)
This depth is used to determine the original caller's location
for stack traces.
"""
STACK_TRACE_DEPTH = 2
"""
Default setting for whether to resolve symbolic links when copying files.
When False, symlinks are copied as symlinks rather than following them.
"""
RESOLVE_SYMLINKS = False
"""
Default setting for whether to gzip files when copying them into the
template. When True, the upload archive is gzipped before being uploaded.
"""
GZIP = True
"""
Default timeout (in seconds) for uploading the build-context archive to the
S3 presigned URL. Uploads of large archives can take far longer than the 60s
general API request timeout, so the upload uses a 1-hour default unless the
caller passes an explicit ``request_timeout``. This matches the JS SDK's
``FILE_UPLOAD_TIMEOUT_MS``.
"""
FILE_UPLOAD_TIMEOUT_SECONDS = 3600
@@ -0,0 +1,286 @@
import json
import os
import re
import tempfile
from typing import Dict, List, Optional, Protocol, Union, Literal
from dockerfile_parse import DockerfileParser
class DockerfFileFinalParserInterface(Protocol):
"""Protocol defining the final interface for Dockerfile parsing callbacks."""
class DockerfileParserInterface(Protocol):
"""Protocol defining the interface for Dockerfile parsing callbacks."""
def run_cmd(
self, command: Union[str, List[str]], user: Optional[str] = None
) -> "DockerfileParserInterface":
"""Handle RUN instruction."""
...
def copy(
self,
src: str,
dest: str,
force_upload: Optional[Literal[True]] = None,
user: Optional[str] = None,
mode: Optional[int] = None,
resolve_symlinks: Optional[bool] = None,
gzip: Optional[bool] = None,
) -> "DockerfileParserInterface":
"""Handle COPY instruction."""
...
def set_workdir(self, workdir: str) -> "DockerfileParserInterface":
"""Handle WORKDIR instruction."""
...
def set_user(self, user: str) -> "DockerfileParserInterface":
"""Handle USER instruction."""
...
def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface":
"""Handle ENV instruction."""
...
def set_start_cmd(
self, start_cmd: str, ready_cmd: str
) -> "DockerfFileFinalParserInterface":
"""Handle CMD/ENTRYPOINT instruction."""
...
def parse_dockerfile(
dockerfile_content_or_path: str, template_builder: DockerfileParserInterface
) -> str:
"""
Parse a Dockerfile and convert it to Template SDK format.
:param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file
:param template_builder: Interface providing template builder methods
:return: The base image from the Dockerfile
:raises ValueError: If the Dockerfile is invalid or unsupported
"""
# Check if input is a file path that exists
if os.path.isfile(dockerfile_content_or_path):
# Read the file content
with open(dockerfile_content_or_path, "r", encoding="utf-8") as f:
dockerfile_content = f.read()
else:
# Treat as content directly
dockerfile_content = dockerfile_content_or_path
# Use a temporary directory to avoid creating files in the current directory
with tempfile.TemporaryDirectory() as temp_dir:
# Create a temporary Dockerfile
dockerfile_path = os.path.join(temp_dir, "Dockerfile")
with open(dockerfile_path, "w") as f:
f.write(dockerfile_content)
dfp = DockerfileParser(path=temp_dir)
# Check for multi-stage builds
from_instructions = [
instruction
for instruction in dfp.structure
if instruction["instruction"] == "FROM"
]
if len(from_instructions) > 1:
raise ValueError("Multi-stage Dockerfiles are not supported")
if len(from_instructions) == 0:
raise ValueError("Dockerfile must contain a FROM instruction")
# Set the base image from the first FROM instruction
base_image = from_instructions[0]["value"]
# Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18")
if " as " in base_image.lower():
base_image = base_image.split(" as ")[0].strip()
user_changed = False
workdir_changed = False
# Set the user and workdir to the Docker defaults
template_builder.set_user("root")
template_builder.set_workdir("/")
# Process all other instructions
for instruction_data in dfp.structure:
instruction = instruction_data["instruction"]
value = instruction_data["value"]
if instruction == "FROM":
# Already handled above
continue
elif instruction == "RUN":
_handle_run_instruction(value, template_builder)
elif instruction in ["COPY", "ADD"]:
_handle_copy_instruction(value, template_builder)
elif instruction == "WORKDIR":
_handle_workdir_instruction(value, template_builder)
workdir_changed = True
elif instruction == "USER":
_handle_user_instruction(value, template_builder)
user_changed = True
elif instruction in ["ENV", "ARG"]:
_handle_env_instruction(value, instruction, template_builder)
elif instruction in ["CMD", "ENTRYPOINT"]:
_handle_cmd_entrypoint_instruction(value, template_builder)
else:
print(f"Unsupported instruction: {instruction}")
continue
# Set the user and workdir to the E2B defaults
if not user_changed:
template_builder.set_user("user")
if not workdir_changed:
template_builder.set_workdir("/home/user")
return base_image
def _handle_run_instruction(
value: str, template_builder: DockerfileParserInterface
) -> None:
"""Handle RUN instruction"""
if not value.strip():
return
# Remove line continuations and normalize whitespace
command = re.sub(r"\\\s*\n\s*", " ", value).strip()
template_builder.run_cmd(command)
def _handle_copy_instruction(
value: str, template_builder: DockerfileParserInterface
) -> None:
"""Handle COPY/ADD instruction"""
if not value.strip():
return
# Parse source and destination from COPY/ADD command
# Handle both quoted and unquoted paths
parts = []
current_part = ""
in_quotes = False
quote_char = None
i = 0
while i < len(value):
char = value[i]
if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"):
if not in_quotes:
in_quotes = True
quote_char = char
elif char == quote_char:
in_quotes = False
quote_char = None
else:
current_part += char
elif char == " " and not in_quotes:
if current_part:
parts.append(current_part)
current_part = ""
else:
current_part += char
i += 1
if current_part:
parts.append(current_part)
# Extract --chown flag and separate from paths
user = None
non_flag_parts = []
for part in parts:
if part.startswith("--chown="):
user = part[8:] # Extract value after "--chown="
elif not part.startswith("--"):
non_flag_parts.append(part)
if len(non_flag_parts) >= 2:
dest = non_flag_parts[-1] # Last part is destination
sources = non_flag_parts[:-1]
for src in sources:
template_builder.copy(src, dest, user=user)
def _handle_workdir_instruction(
value: str, template_builder: DockerfileParserInterface
) -> None:
"""Handle WORKDIR instruction"""
if not value.strip():
return
workdir = value.strip()
template_builder.set_workdir(workdir)
def _handle_user_instruction(
value: str, template_builder: DockerfileParserInterface
) -> None:
"""Handle USER instruction"""
if not value.strip():
return
user = value.strip()
template_builder.set_user(user)
def _handle_env_instruction(
value: str, instruction_type: str, template_builder: DockerfileParserInterface
) -> None:
"""Handle ENV/ARG instruction"""
if not value.strip():
return
# Parse environment variables from the value
# Handle both "KEY=value" and "KEY value" formats
env_vars = {}
# First try to split on = for KEY=value format
if "=" in value:
# Handle multiple KEY=value pairs on one line
pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value)
for key, val in pairs:
env_vars[key] = val.strip("\"'")
else:
# Handle "KEY value" format
parts = value.split(None, 1)
if len(parts) == 2:
key, val = parts
env_vars[key] = val.strip("\"'")
elif len(parts) == 1 and instruction_type == "ARG":
# ARG without default value
key = parts[0]
env_vars[key] = ""
# Add each environment variable
if env_vars:
template_builder.set_envs(env_vars)
def _handle_cmd_entrypoint_instruction(
value: str, template_builder: DockerfileParserInterface
) -> None:
"""Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout"""
if not value.strip():
return
command = value.strip()
# Try to parse as JSON (for array format like CMD ["sleep", "infinity"])
try:
parsed_command = json.loads(command)
if isinstance(parsed_command, list):
command = " ".join(str(item) for item in parsed_command)
except Exception:
pass
# Import wait_for_timeout locally to avoid circular dependency
def wait_for_timeout(timeout: int) -> str:
# convert to seconds, but ensure minimum of 1 second
seconds = max(1, timeout // 1000)
return f"sleep {seconds}"
template_builder.set_start_cmd(command, wait_for_timeout(20_000))
+232
View File
@@ -0,0 +1,232 @@
import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, TypedDict, Callable, Dict, Literal
from rich.console import Console
from rich.style import Style
from rich.text import Text
from e2b.template.utils import strip_ansi_escape_codes
"""Log entry severity levels."""
LogEntryLevel = Literal["debug", "info", "warn", "error"]
@dataclass
class LogEntry:
"""
Represents a single log entry from the template build process.
"""
timestamp: datetime
level: LogEntryLevel
message: str
def __post_init__(self):
self.message = strip_ansi_escape_codes(self.message)
def __str__(self) -> str:
return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}"
@dataclass
class LogEntryStart(LogEntry):
"""
Special log entry indicating the start of a build process.
"""
level: LogEntryLevel = field(default="debug", init=False)
@dataclass
class LogEntryEnd(LogEntry):
"""
Special log entry indicating the end of a build process.
"""
level: LogEntryLevel = field(default="debug", init=False)
"""
Interval in milliseconds for updating the build timer display.
"""
TIMER_UPDATE_INTERVAL_MS = 150
"""
Default minimum log level to display.
"""
DEFAULT_LEVEL: LogEntryLevel = "info"
"""
Colored labels for each log level.
"""
levels: Dict[LogEntryLevel, tuple[str, Style]] = {
"error": ("ERROR", Style(color="red")),
"warn": ("WARN ", Style(color="#FF4400")),
"info": ("INFO ", Style(color="#FF8800")),
"debug": ("DEBUG", Style(color="bright_black")),
}
"""
Numeric ordering of log levels for comparison (lower = less severe).
"""
level_order = {
"debug": 0,
"info": 1,
"warn": 2,
"error": 3,
}
def set_interval(func, interval):
"""
Returns a stop function that can be called to cancel the interval.
Similar to JavaScript's setInterval.
:param func: Function to execute at each interval
:param interval: Interval duration in **seconds**
:return: Stop function that can be called to cancel the interval
"""
stopped = threading.Event()
def loop():
while not stopped.is_set():
if stopped.wait(interval): # wait returns True if stopped
break
if not stopped.is_set(): # Double-check before executing
func()
threading.Thread(target=loop, daemon=True).start()
return stopped.set # Return the stop function
class DefaultBuildLoggerInitialState(TypedDict):
start_time: float
animation_frame: int
timer: Optional[Callable[[], None]]
class DefaultBuildLogger:
__console = Console()
__min_level: LogEntryLevel
__state: DefaultBuildLoggerInitialState
def __init__(self, min_level: Optional[LogEntryLevel] = None):
self.__min_level = min_level if min_level is not None else DEFAULT_LEVEL
self.__reset_initial_state()
def logger(self, log):
if isinstance(log, LogEntryStart):
self.__start_timer()
return
if isinstance(log, LogEntryEnd):
if self.__state["timer"] is not None:
self.__state["timer"]()
return
# Filter by minimum level
if level_order[log.level] < level_order[self.__min_level]:
return
formatted_line = self.__format_log_line(log)
self.__console.print(formatted_line)
# Redraw the timer line
self.__update_timer()
def __reset_initial_state(self, timer: Optional[Callable[[], None]] = None):
self.__state = {
"start_time": time.time(),
"animation_frame": 0,
"timer": timer,
}
def __format_timer_line(self) -> str:
elapsed_seconds = time.time() - self.__state["start_time"]
return f"{elapsed_seconds:.1f}s"
def __animate_status(self) -> str:
frames = ["", "", "", "", "", "", "", ""]
idx = self.__state["animation_frame"] % len(frames)
return frames[idx]
def __format_log_line(self, line: LogEntry) -> Text:
timer = self.__format_timer_line().ljust(5)
timestamp = line.timestamp.strftime("%H:%M:%S")
level_text, level_style = levels.get(line.level, levels[DEFAULT_LEVEL])
# Build a rich Text object
text = Text.assemble(
timer,
" | ",
(timestamp, "dim"),
" ",
(level_text, level_style),
" ",
line.message,
)
return text
def __start_timer(self):
if not sys.stdout.isatty():
return
# Start the timer interval
stop_timer = set_interval(
self.__update_timer, TIMER_UPDATE_INTERVAL_MS / 1000.0
)
self.__reset_initial_state(stop_timer)
# Initial timer display
self.__update_timer()
def __update_timer(self):
if not sys.stdout.isatty():
return
self.__state["animation_frame"] += 1
jumping_squares = self.__animate_status()
timer_text = Text.assemble(
jumping_squares, " Building ", self.__format_timer_line()
)
# Print with carriage return
self.__console.print(timer_text, end="\r")
def default_build_logger(
min_level: Optional[LogEntryLevel] = None,
) -> Callable[[LogEntry], None]:
"""
Create a default build logger with animated timer display.
:param min_level: Minimum log level to display (default: 'info')
:return: Logger function that accepts LogEntry instances
Example
```python
from e2b import Template, default_build_logger
template = Template().from_python_image()
# Use with build - implementation would be in build_async module
# await Template.build(template,
# alias='my-template',
# on_build_logs=default_build_logger(min_level='debug')
# )
```
"""
build_logger = DefaultBuildLogger(min_level)
return build_logger.logger
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
import shlex
class ReadyCmd:
"""
Wrapper class for ready check commands.
"""
def __init__(self, cmd: str):
self.__cmd = cmd
def get_cmd(self):
return self.__cmd
def wait_for_port(port: int):
"""
Wait for a port to be listening.
Uses `ss` command to check if a port is open and listening.
:param port: Port number to wait for
:return: ReadyCmd that checks for the port
Example
```python
from e2b import Template, wait_for_port
template = (
Template()
.from_python_image()
.set_start_cmd('python -m http.server 8000', wait_for_port(8000))
)
```
"""
# Match the exact listening port via ss's source-port filter (so e.g. port
# 80 doesn't match 8080). ss exits 0 regardless of matches, so test for
# non-empty output to signal readiness.
cmd = f'[ -n "$(ss -Htuln sport = :{port})" ]'
return ReadyCmd(cmd)
def wait_for_url(url: str, status_code: int = 200):
"""
Wait for a URL to return a specific HTTP status code.
Uses `curl` to make HTTP requests and check the response status.
:param url: URL to check (e.g., 'http://localhost:3000/health')
:param status_code: Expected HTTP status code (default: 200)
:return: ReadyCmd that checks the URL
Example
```python
from e2b import Template, wait_for_url
template = (
Template()
.from_node_image()
.set_start_cmd('npm start', wait_for_url('http://localhost:3000/health'))
)
```
"""
cmd = f'curl -s -o /dev/null -w "%{{http_code}}" {shlex.quote(url)} | grep -q "{status_code}"'
return ReadyCmd(cmd)
def wait_for_process(process_name: str):
"""
Wait for a process with a specific name to be running.
Uses `pgrep` to check if a process exists.
:param process_name: Name of the process to wait for
:return: ReadyCmd that checks for the process
Example
```python
from e2b import Template, wait_for_process
template = (
Template()
.from_base_image()
.set_start_cmd('./my-daemon', wait_for_process('my-daemon'))
)
```
"""
cmd = f"pgrep {shlex.quote(process_name)} > /dev/null"
return ReadyCmd(cmd)
def wait_for_file(filename: str):
"""
Wait for a file to exist.
Uses shell test command to check file existence.
:param filename: Path to the file to wait for
:return: ReadyCmd that checks for the file
Example
```python
from e2b import Template, wait_for_file
template = (
Template()
.from_base_image()
.set_start_cmd('./init.sh', wait_for_file('/tmp/ready'))
)
```
"""
cmd = f"[ -f {shlex.quote(filename)} ]"
return ReadyCmd(cmd)
def wait_for_timeout(timeout: int):
"""
Wait for a specified timeout before considering the sandbox ready.
Uses `sleep` command to wait for a fixed duration.
:param timeout: Time to wait in **milliseconds** (minimum: 1000ms / 1 second)
:return: ReadyCmd that waits for the specified duration
Example
```python
from e2b import Template, wait_for_timeout
template = (
Template()
.from_node_image()
.set_start_cmd('npm start', wait_for_timeout(5000)) # Wait 5 seconds
)
```
"""
# convert to seconds, but ensure minimum of 1 second
seconds = max(1, timeout // 1000)
cmd = f"sleep {seconds}"
return ReadyCmd(cmd)
+194
View File
@@ -0,0 +1,194 @@
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import List, Literal, Optional, TypedDict, Union
from typing_extensions import NotRequired
from e2b.template.logger import LogEntry
class TemplateBuildStatus(str, Enum):
"""
Status of a template build.
"""
BUILDING = "building"
WAITING = "waiting"
READY = "ready"
ERROR = "error"
@dataclass
class BuildStatusReason:
"""
Reason for the current build status (typically for errors).
"""
message: str
"""Message with the status reason."""
step: Optional[str] = None
"""Step that failed."""
log_entries: List[LogEntry] = field(default_factory=list)
"""Log entries related to the status reason."""
@dataclass
class TemplateBuildStatusResponse:
"""
Response from getting build status.
"""
build_id: str
"""Build identifier."""
template_id: str
"""Template identifier."""
status: TemplateBuildStatus
"""Current status of the build."""
log_entries: List[LogEntry]
"""Build log entries."""
logs: List[str]
"""Build logs (raw strings). Deprecated: use log_entries instead."""
reason: Optional[BuildStatusReason] = None
"""Reason for the current status (typically for errors)."""
@dataclass
class TemplateTagInfo:
"""
Information about assigned template tags.
"""
build_id: str
"""Build identifier associated with this tag."""
tags: List[str]
"""Assigned tags of the template."""
@dataclass
class TemplateTag:
"""
Detailed information about a single template tag.
"""
tag: str
"""Name of the tag."""
build_id: str
"""Build identifier associated with this tag."""
created_at: datetime
"""When this tag was assigned."""
class InstructionType(str, Enum):
"""
Types of instructions that can be used in a template.
"""
COPY = "COPY"
ENV = "ENV"
RUN = "RUN"
WORKDIR = "WORKDIR"
USER = "USER"
class CopyItem(TypedDict):
"""
Configuration for a single file/directory copy operation.
"""
src: Union[Union[str, Path], List[Union[str, Path]]]
dest: Union[str, Path]
forceUpload: NotRequired[Optional[Literal[True]]]
user: NotRequired[Optional[str]]
mode: NotRequired[Optional[int]]
resolveSymlinks: NotRequired[Optional[bool]]
gzip: NotRequired[Optional[bool]]
class Instruction(TypedDict):
"""
Represents a single instruction in the template build process.
"""
type: InstructionType
args: List[str]
force: bool
forceUpload: NotRequired[Optional[Literal[True]]]
filesHash: NotRequired[Optional[str]]
resolveSymlinks: NotRequired[Optional[bool]]
gzip: NotRequired[Optional[bool]]
class GenericDockerRegistry(TypedDict):
"""
Configuration for a generic Docker registry with basic authentication.
"""
type: Literal["registry"]
username: str
password: str
class AWSRegistry(TypedDict):
"""
Configuration for AWS Elastic Container Registry (ECR).
"""
type: Literal["aws"]
awsAccessKeyId: str
awsSecretAccessKey: str
awsRegion: str
class GCPRegistry(TypedDict):
"""
Configuration for Google Container Registry (GCR) or Artifact Registry.
"""
type: Literal["gcp"]
serviceAccountJson: str
"""
Union type for all supported container registry configurations.
"""
RegistryConfig = Union[GenericDockerRegistry, AWSRegistry, GCPRegistry]
class TemplateType(TypedDict):
"""
Internal representation of a template for the E2B build API.
"""
fromImage: NotRequired[str]
fromTemplate: NotRequired[str]
fromImageRegistry: NotRequired[RegistryConfig]
startCmd: NotRequired[str]
readyCmd: NotRequired[str]
steps: List[Instruction]
force: bool
@dataclass
class BuildInfo:
"""
Information about a built template.
"""
template_id: str
build_id: str
name: str
# Deprecated: use name instead
alias: str
tags: List[str] = field(default_factory=list)
+426
View File
@@ -0,0 +1,426 @@
import hashlib
import os
import tarfile
import tempfile
import json
import stat
from wcmatch import glob
import re
import inspect
from types import TracebackType, FrameType
from typing import IO, List, Optional, Union
from e2b.exceptions import TemplateException
from e2b.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME
def make_traceback(caller_frame: Optional[FrameType]) -> Optional[TracebackType]:
"""
Create a TracebackType from a caller frame for error reporting.
:param caller_frame: The caller's frame object, or None
:return: A TracebackType object for use with exception.with_traceback(), or None
"""
if caller_frame is None:
return None
return TracebackType(
tb_next=None,
tb_frame=caller_frame,
tb_lasti=caller_frame.f_lasti,
tb_lineno=caller_frame.f_lineno,
)
def validate_relative_path(
src: str,
stack_trace: Optional[TracebackType],
) -> None:
"""
Validate that a source path for copy operations is a relative path that stays
within the context directory. This prevents path traversal attacks and ensures
files are copied from within the expected directory.
:param src: The source path to validate
:param stack_trace: Optional stack trace for error reporting
:raises TemplateException: If the path is absolute or escapes the context directory
Invalid paths:
- Absolute paths: /absolute/path, C:\\Windows\\path
- Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar
Valid paths:
- Simple relative: foo, foo/bar
- Current directory prefix: ./foo, ./foo/bar
- Internal parent refs that don't escape: foo/../bar (stays within context)
"""
# Check for absolute paths using Python's cross-platform implementation
if os.path.isabs(src):
raise TemplateException(
f'Invalid source path "{src}": absolute paths are not allowed. '
"Use a relative path within the context directory."
).with_traceback(stack_trace)
# Normalize the path and check if it escapes the context directory
normalized = os.path.normpath(src)
# After normalization, a path that escapes would be '..' or start with '../'
# We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig'
# Examples:
# - '../foo' -> '../foo' (escapes)
# - 'foo/../../bar' -> '../bar' (escapes)
# - './foo/../../../bar' -> '../../bar' (escapes)
# - 'foo/../bar' -> 'bar' (doesn't escape)
# - './foo/bar' -> 'foo/bar' (doesn't escape)
# - '..myconfig' -> '..myconfig' (valid filename, doesn't escape)
escapes = normalized == ".." or normalized.startswith(".." + os.sep)
if escapes:
raise TemplateException(
f'Invalid source path "{src}": path escapes the context directory. '
"The path must stay within the context directory."
).with_traceback(stack_trace)
def normalize_build_arguments(
name: Optional[str] = None,
alias: Optional[str] = None,
) -> str:
"""
Normalize build arguments from different parameter signatures.
Handles string name or legacy alias parameter.
:param name: Template name in 'name' or 'name:tag' format
:param alias: (Deprecated) Alias name for the template. Use name instead.
:return: Normalized template name
:raises TemplateException: If no template name is provided
"""
if name and len(name) > 0:
return name
if alias and len(alias) > 0:
return alias
raise TemplateException("Name must be provided")
def read_dockerignore(context_path: str) -> List[str]:
"""
Read and parse a .dockerignore file.
:param context_path: Directory path containing the .dockerignore file
:return: Array of ignore patterns (empty lines and comments are filtered out)
"""
dockerignore_path = os.path.join(context_path, ".dockerignore")
if not os.path.exists(dockerignore_path):
return []
with open(dockerignore_path, "r", encoding="utf-8") as f:
content = f.read()
return [
line.strip()
for line in content.split("\n")
if line.strip() and not line.strip().startswith("#")
]
def normalize_path(path: str) -> str:
"""
Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows).
:param path: The path to normalize
:return: The normalized path
"""
return path.replace(os.sep, "/")
def get_all_files_in_path(
src: str,
context_path: str,
ignore_patterns: List[str],
include_directories: bool = True,
) -> List[str]:
"""
Get all files for a given path and ignore patterns.
:param src: Path to the source directory
:param context_path: Base directory for resolving relative paths
:param ignore_patterns: Ignore patterns
:param include_directories: Whether to include directories
:return: Array of files
"""
files = set()
# Use glob to find all files/directories matching the pattern under context_path
abs_context_path = os.path.abspath(context_path)
files_glob = glob.glob(
src,
flags=glob.GLOBSTAR | glob.DOTMATCH,
root_dir=abs_context_path,
exclude=ignore_patterns,
)
for file in files_glob:
# Join it with abs_context_path to get the absolute path
file_path = os.path.join(abs_context_path, file)
if os.path.isdir(file_path):
# If it's a directory, add the directory and all entries recursively
if include_directories:
files.add(file_path)
dir_files = glob.glob(
normalize_path(file) + "/**/*",
flags=glob.GLOBSTAR | glob.DOTMATCH,
root_dir=abs_context_path,
exclude=ignore_patterns,
)
for dir_file in dir_files:
dir_file_path = os.path.join(abs_context_path, dir_file)
files.add(dir_file_path)
else:
files.add(file_path)
return sorted(list(files))
def calculate_files_hash(
src: str,
dest: str,
context_path: str,
ignore_patterns: List[str],
resolve_symlinks: bool,
stack_trace: Optional[TracebackType],
) -> str:
"""
Calculate a hash of files being copied to detect changes for cache invalidation.
The hash includes file content, metadata (mode, size), and relative paths.
Note: uid, gid, and mtime are excluded to ensure stable hashes across environments.
:param src: Source path pattern for files to copy
:param dest: Destination path where files will be copied
:param context_path: Base directory for resolving relative paths
:param ignore_patterns: Glob patterns to ignore
:param resolve_symlinks: Whether to resolve symbolic links when hashing
:param stack_trace: Optional stack trace for error reporting
:return: Hex string hash of all files
:raises ValueError: If no files match the source pattern
"""
src_path = os.path.join(context_path, src)
hash_obj = hashlib.sha256()
content = f"COPY {src} {dest}"
hash_obj.update(content.encode())
files = get_all_files_in_path(src, context_path, ignore_patterns, True)
if len(files) == 0:
raise ValueError(f"No files found in {src_path}").with_traceback(stack_trace)
def hash_stats(stat_info: os.stat_result) -> None:
# Only include stable metadata (mode, size)
# Exclude uid, gid, and mtime to ensure consistent hashes across environments
hash_obj.update(str(stat_info.st_mode).encode())
hash_obj.update(str(stat_info.st_size).encode())
for file in files:
# Hash the relative path
relative_path = os.path.relpath(file, context_path)
hash_obj.update(relative_path.encode())
# Add stat information to hash calculation
if os.path.islink(file):
stats = os.lstat(file)
should_follow = resolve_symlinks and (
os.path.isfile(file) or os.path.isdir(file)
)
if not should_follow:
hash_stats(stats)
content = os.readlink(file)
hash_obj.update(content.encode())
continue
stats = os.stat(file)
hash_stats(stats)
if stat.S_ISREG(stats.st_mode):
with open(file, "rb") as f:
hash_obj.update(f.read())
return hash_obj.hexdigest()
def tar_file_stream(
file_name: str,
file_context_path: str,
ignore_patterns: List[str],
resolve_symlinks: bool,
gzip: bool,
) -> IO[bytes]:
"""
Create a tar archive of files matching a pattern in a temporary file.
The archive is spooled to disk so it can be uploaded as a stream instead
of being buffered in memory. The temporary file is deleted when closed.
:param file_name: Glob pattern for files to include
:param file_context_path: Base directory for resolving file paths
:param ignore_patterns: Ignore patterns
:param resolve_symlinks: Whether to resolve symbolic links
:param gzip: Whether to gzip the archive
:return: Binary file object positioned at the start of the archive
"""
tar_file = tempfile.TemporaryFile()
try:
with tarfile.open(
fileobj=tar_file,
mode="w:gz" if gzip else "w",
dereference=resolve_symlinks,
) as tar:
files = get_all_files_in_path(
file_name, file_context_path, ignore_patterns, True
)
for file in files:
tar.add(
file,
arcname=os.path.relpath(file, file_context_path),
recursive=False,
)
tar_file.seek(0)
return tar_file
except Exception:
# Best-effort cleanup: a close failure must not replace the real
# archive-creation error.
try:
tar_file.close()
except Exception:
pass
raise
def strip_ansi_escape_codes(text: str) -> str:
"""
Strip ANSI escape codes from a string.
Source: https://github.com/chalk/ansi-regex/blob/main/index.js
:param text: String with ANSI escape codes
:return: String without ANSI escape codes
"""
# Valid string terminator sequences are BEL, ESC\, and 0x9c
st = r"(?:\u0007|\u001B\u005C|\u009C)"
pattern = [
rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})",
r"(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
]
ansi_escape = re.compile("|".join(pattern), re.UNICODE)
return ansi_escape.sub("", text)
def get_caller_frame(depth: int) -> Optional[FrameType]:
"""
Get the caller's stack frame at a specific depth.
This is used to provide better error messages and debugging information
by tracking where template methods were called from in user code.
:param depth: The depth of the stack trace to retrieve
:return: The caller frame, or None if not available
"""
stack = inspect.stack()[1:]
if len(stack) < depth + 1:
return None
return stack[depth].frame
def get_caller_directory(depth: int) -> Optional[str]:
"""
Get the directory of the caller at a specific stack depth.
This is used to determine the file_context_path when creating a template,
so file paths are resolved relative to the user's template file location.
:param depth: The depth of the stack trace
:return: The caller's directory path, or None if not available
"""
try:
# Get the stack trace
caller_frame = get_caller_frame(depth)
if caller_frame is None:
return None
caller_file = caller_frame.f_code.co_filename
# Return the directory of the caller file
return os.path.dirname(os.path.abspath(caller_file))
except Exception:
return None
def pad_octal(mode: int) -> str:
"""
Convert a numeric file mode to a zero-padded octal string.
:param mode: File mode as a number (e.g., 493 for 0o755)
:return: Zero-padded 4-digit octal string (e.g., "0755")
Example
```python
pad_octal(0o755) # Returns "0755"
pad_octal(0o644) # Returns "0644"
```
"""
return f"{mode:04o}"
def get_build_step_index(step: str, stack_traces_length: int) -> int:
"""
Get the array index for a build step based on its name.
Special steps:
- BASE_STEP_NAME: Returns 0 (first step)
- FINALIZE_STEP_NAME: Returns the last index
- Numeric strings: Converted to number
:param step: Build step name or number as string
:param stack_traces_length: Total number of stack traces (used for FINALIZE_STEP_NAME)
:return: Index for the build step
"""
if step == BASE_STEP_NAME:
return 0
if step == FINALIZE_STEP_NAME:
return stack_traces_length - 1
return int(step)
def read_gcp_service_account_json(
context_path: str, path_or_content: Union[str, dict]
) -> str:
"""
Read GCP service account JSON from a file or object.
:param context_path: Base directory for resolving relative file paths
:param path_or_content: Either a path to a JSON file or a service account object
:return: Service account JSON as a string
"""
if isinstance(path_or_content, str):
with open(
os.path.join(context_path, path_or_content), "r", encoding="utf-8"
) as f:
return f.read()
else:
return json.dumps(path_or_content)