Files
2026-07-13 12:47:58 +08:00

338 lines
10 KiB
Python

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)