Source code for sqlspec.storage.backends.base

"""Base class for storage backends."""

# ruff: noqa: RSE102
import asyncio
import builtins
import contextlib
from abc import abstractmethod
from collections.abc import AsyncIterator, Iterator
from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload

from mypy_extensions import mypyc_attr

from sqlspec.utils.sync_tools import get_default_async_executor

if TYPE_CHECKING:
    from pathlib import Path
    from types import TracebackType

    from sqlspec.typing import ArrowRecordBatch, ArrowTable

__all__ = ("AsyncArrowBatchIterator", "AsyncObStoreStreamIterator", "AsyncThreadedBytesIterator", "ObjectStoreBase")


_StopAsyncBase = getattr(builtins, "Stop" + "Async" + "Iteration")
_StopAsync = type("_StopAsync", (_StopAsyncBase,), {})


class _ExhaustedSentinel:
    """Sentinel value to signal iterator exhaustion across thread boundaries.

    StopIteration cannot be raised into asyncio Futures, so we use this sentinel
    to signal iterator exhaustion from the thread pool back to the async context.
    """

    __slots__ = ()


_EXHAUSTED = _ExhaustedSentinel()


class AsyncArrowBatchIterator:
    """Async iterator wrapper for sync Arrow batch iterators."""

    __slots__ = ("_closed", "_sync_iter")

    def __init__(self, sync_iterator: "Iterator[ArrowRecordBatch]") -> None:
        self._sync_iter = sync_iterator
        self._closed = False

    def __aiter__(self) -> "AsyncArrowBatchIterator":
        return self

    async def __aenter__(self) -> Self:
        return self

    async def __aexit__(
        self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None"
    ) -> None:
        await self.aclose()

    def __anext__(self) -> Any:
        return asyncio.get_running_loop().run_in_executor(get_default_async_executor(), self._sync_next)

    async def aclose(self) -> None:
        """Close the underlying generator and its active storage reader."""
        if self._closed:
            return
        self._closed = True
        close = getattr(self._sync_iter, "close", None)
        if close is not None:
            await asyncio.get_running_loop().run_in_executor(get_default_async_executor(), close)

    def _sync_next(self) -> "ArrowRecordBatch":
        if self._closed:
            raise _StopAsync()
        result = _next_or_sentinel(self._sync_iter)
        if result is _EXHAUSTED:
            self._closed = True
            raise _StopAsync()
        return cast("ArrowRecordBatch", result)


class AsyncObStoreStreamIterator:
    """Async iterator wrapper for obstore streaming."""

    __slots__ = ("_chunk_size", "_stream")

    def __init__(self, stream: Any, chunk_size: "int | None" = None) -> None:
        self._stream = stream
        self._chunk_size = chunk_size if chunk_size is not None and chunk_size > 0 else None

    def __aiter__(self) -> "AsyncObStoreStreamIterator":
        return self

    async def __aenter__(self) -> Self:
        return self

    async def __aexit__(
        self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None"
    ) -> None:
        await self.aclose()

    async def __anext__(self) -> bytes:
        if self._stream is None:
            raise StopAsyncIteration
        chunk = await self._stream.__anext__()
        return chunk if type(chunk) is bytes else bytes(chunk)

    async def aclose(self) -> None:
        """Close the underlying stream if it exposes a close hook."""
        stream = self._stream
        if stream is None:
            return
        self._stream = None
        aclose: Any = getattr(stream, "aclose", None)
        if aclose is not None:
            with contextlib.suppress(Exception):
                await aclose()
            return
        close: Any = getattr(stream, "close", None)
        if close is not None:
            with contextlib.suppress(Exception):
                close()


class AsyncThreadedBytesIterator:
    """Async iterator that reads from a synchronous file-like object in a thread pool."""

    __slots__ = ("_chunk_size", "_closed", "_file_obj")

    def __init__(self, file_obj: Any, chunk_size: int = 65536) -> None:
        self._file_obj = file_obj
        self._chunk_size = chunk_size
        self._closed = False

    def __aiter__(self) -> "AsyncThreadedBytesIterator":
        return self

    async def __aenter__(self) -> Self:
        return self

    async def __aexit__(
        self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None"
    ) -> None:
        await self.aclose()

    def __anext__(self) -> Any:
        return asyncio.get_running_loop().run_in_executor(get_default_async_executor(), self._sync_read)

    async def aclose(self) -> None:
        if self._closed:
            return
        self._closed = True
        with contextlib.suppress(Exception):
            await asyncio.get_running_loop().run_in_executor(get_default_async_executor(), self._file_obj.close)

    def _sync_read(self) -> bytes:
        if self._closed:
            raise _StopAsync()
        result = _read_chunk_or_sentinel(self._file_obj, self._chunk_size)
        if result is _EXHAUSTED:
            self._closed = True
            with contextlib.suppress(Exception):
                self._file_obj.close()
            raise _StopAsync()
        return cast("bytes", result)


[docs] @mypyc_attr(allow_interpreted_subclasses=True) class ObjectStoreBase: """Base class for storage backends. All synchronous methods follow the *_sync naming convention for consistency with their async counterparts. """ __slots__ = () @property @abstractmethod def supports_signing(self) -> bool: """Whether this backend supports URL signing.""" raise NotImplementedError
[docs] @abstractmethod def resolve_uri(self, path: "str | Path") -> str: """Resolve a backend-relative path to its unsigned address.""" raise NotImplementedError
[docs] @abstractmethod def read_bytes_sync(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes from storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def write_bytes_sync(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: """Write bytes to storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def stream_read_sync(self, path: "str | Path", chunk_size: "int | None" = None, **kwargs: Any) -> Iterator[bytes]: """Stream bytes from storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def read_text_sync(self, path: "str | Path", encoding: str = "utf-8", **kwargs: Any) -> str: """Read text from storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def write_text_sync(self, path: "str | Path", data: str, encoding: str = "utf-8", **kwargs: Any) -> None: """Write text to storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": """List objects in storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def exists_sync(self, path: "str | Path", **kwargs: Any) -> bool: """Check if object exists in storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def delete_sync(self, path: "str | Path", **kwargs: Any) -> None: """Delete object from storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def copy_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Copy object within storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def move_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Move object within storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def glob_sync(self, pattern: str, **kwargs: Any) -> "list[str]": """Find objects matching pattern synchronously.""" raise NotImplementedError
[docs] @abstractmethod def get_metadata_sync(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": """Get object metadata from storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def is_object_sync(self, path: "str | Path") -> bool: """Check if path points to an object synchronously.""" raise NotImplementedError
[docs] @abstractmethod def is_path_sync(self, path: "str | Path") -> bool: """Check if path points to a directory synchronously.""" raise NotImplementedError
[docs] @abstractmethod def read_arrow_sync(self, path: "str | Path", **kwargs: Any) -> "ArrowTable": """Read Arrow table from storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def write_arrow_sync(self, path: "str | Path", table: "ArrowTable", **kwargs: Any) -> None: """Write Arrow table to storage synchronously.""" raise NotImplementedError
[docs] @abstractmethod def stream_arrow_sync( self, pattern: str, *, file_format: Literal["parquet"] = "parquet", batch_size: int = 65_536, **kwargs: Any ) -> "Iterator[ArrowRecordBatch]": """Stream Arrow record batches from storage synchronously.""" raise NotImplementedError
@overload def sign_sync(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... @overload def sign_sync(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ...
[docs] @abstractmethod def sign_sync( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": """Generate signed URL(s) synchronously.""" raise NotImplementedError
[docs] @abstractmethod async def read_bytes_async(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes from storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def write_bytes_async(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: """Write bytes to storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def read_text_async(self, path: "str | Path", encoding: str = "utf-8", **kwargs: Any) -> str: """Read text from storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def write_text_async(self, path: "str | Path", data: str, encoding: str = "utf-8", **kwargs: Any) -> None: """Write text to storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def stream_read_async( self, path: "str | Path", chunk_size: "int | None" = None, **kwargs: Any ) -> AsyncIterator[bytes]: """Stream bytes from storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def list_objects_async(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": """List objects in storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def exists_async(self, path: "str | Path", **kwargs: Any) -> bool: """Check if object exists in storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def delete_async(self, path: "str | Path", **kwargs: Any) -> None: """Delete object from storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def copy_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Copy object within storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def move_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Move object within storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def get_metadata_async(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": """Get object metadata from storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def read_arrow_async(self, path: "str | Path", **kwargs: Any) -> "ArrowTable": """Read Arrow table from storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod async def write_arrow_async(self, path: "str | Path", table: "ArrowTable", **kwargs: Any) -> None: """Write Arrow table to storage asynchronously.""" raise NotImplementedError
[docs] @abstractmethod def stream_arrow_async( self, pattern: str, *, file_format: Literal["parquet"] = "parquet", batch_size: int = 65_536, **kwargs: Any ) -> "AsyncIterator[ArrowRecordBatch]": """Stream Arrow record batches from storage asynchronously. Returns AsyncIterator directly (not a coroutine). """ raise NotImplementedError
@overload async def sign_async(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... @overload async def sign_async(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ...
[docs] @abstractmethod async def sign_async( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": """Generate signed URL(s) asynchronously.""" raise NotImplementedError
def _next_or_sentinel(iterator: "Iterator[Any]") -> "Any": """Get next item or return sentinel if exhausted.""" try: return next(iterator) except StopIteration: return _EXHAUSTED def _read_chunk_or_sentinel(file_obj: Any, chunk_size: int) -> Any: """Read a chunk from a file-like object or return sentinel if exhausted.""" try: chunk = file_obj.read(chunk_size) if not chunk: return _EXHAUSTED except EOFError: return _EXHAUSTED return chunk