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-` 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)