Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ s3 = ["aioboto3>=15.4.0"]
[dependency-groups]
dev = [
"aiosqlite>=0.21.0",
"pillow>=12.0.0",
"pytest>=8.4.2",
"pytest-aioboto3>=0.6.0",
"pytest-asyncio>=1.2.0",
Expand Down
4 changes: 2 additions & 2 deletions src/async_storages/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .base import StorageFile
from .base import StorageFile, StorageImage
from .s3 import S3Storage

__version__ = "0.1.0"
__all__ = ["StorageFile", "S3Storage"]
__all__ = ["StorageFile", "StorageImage", "S3Storage"]
175 changes: 168 additions & 7 deletions src/async_storages/base.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,202 @@
# pyright: reportUnusedParameter=none
from abc import ABC, abstractmethod
import asyncio
from io import BytesIO
from PIL import Image
from typing import BinaryIO


class BaseStorage:
class BaseStorage(ABC):
"""
Abstract base class defining the interface for storage backends.

Subclasses must implement all methods to provide storage operations
such as file upload, deletion, and retrieval of metadata or URLs.
"""

@abstractmethod
def get_name(self, name: str) -> str:
raise NotImplementedError()
"""
Get the name of the file.

:param name: Original file name or identifier.
:type name: str
:return: The name of the file in storage.
:rtype: str
"""
pass

@abstractmethod
async def get_size(self, name: str) -> int:
raise NotImplementedError()
"""
Get the size of the file in bytes.

:param name: Original file name or identifier.
:type name: str
:return: The file size in bytes.
:rtype: int
"""
pass

@abstractmethod
async def get_url(self, name: str) -> str:
raise NotImplementedError()
"""
Get a URL or path to access the file.

:param name: Original file name or identifier.
:type name: str
:return: A URL or file path string.
:rtype: str
"""
pass

@abstractmethod
async def open(self, name: str) -> BytesIO:
"""
Open an object and return it as an in-memory binary stream.

:param name: Original file name or identifier.
:type name: str
:return: A BytesIO object containing the file's contents.
:rtype: BytesIO
"""
pass

@abstractmethod
async def upload(self, file: BinaryIO, name: str) -> str:
raise NotImplementedError()
"""
Upload a file to the storage backend.

:param file: A binary file-like object to upload.
:type file: BinaryIO
:param name: Original file name or identifier.
:type name: str
:return: The name or path of the uploaded file.
:rtype: str
"""
pass

@abstractmethod
async def delete(self, name: str) -> None:
raise NotImplementedError()
"""
Delete the file from the storage backend.

:param name: Original file name or identifier.
:type name: str
:return: None
:rtype: None
"""
pass


class StorageFile:
def __init__(self, name: str, storage: BaseStorage):
"""
File object managed by a storage backend.

:param name: The name or identifier of the stored file.
:type name: str
:param storage: The storage backend handling file operations.
:type storage: BaseStorage
"""

def __init__(self, name: str, storage: BaseStorage) -> None:
self._name: str = name
self._storage: BaseStorage = storage

@property
def name(self) -> str:
"""
Get the name of the file.

:return: The name of the file in storage.
:rtype: str
"""
return self._name

async def get_size(self) -> int:
"""
Get the size of the file in bytes.

:return: The file size in bytes.
:rtype: int
"""
return await self._storage.get_size(self._name)

async def get_url(self) -> str:
"""
Get a URL or path to access the file.

:return: A URL or file path string.
:rtype: str
"""
return await self._storage.get_url(self._name)

async def upload(self, file: BinaryIO) -> str:
"""
Upload a file to the storage backend.

:param file: A binary file-like object to upload.
:type file: BinaryIO
:return: The name or path of the uploaded file.
:rtype: str
"""
return await self._storage.upload(file=file, name=self._name)

async def delete(self) -> None:
"""
Delete the file from the storage backend.

:return: None
:rtype: None
"""
await self._storage.delete(self._name)


class StorageImage(StorageFile):
"""
Image file object managed by a storage backend.
Extends :class:`StorageFile` by including optional image metadata such as width and height.

:param name: The name or identifier of the stored image file.
:type name: str
:param storage: The storage backend handling file operations.
:type storage: BaseStorage
:param width: The width of the image in pixels. Defaults to ``0`` if unknown.
:type width: int, optional
:param height: The height of the image in pixels. Defaults to ``0`` if unknown.
:type height: int, optional
"""

def __init__(
self, name: str, storage: BaseStorage, width: int = 0, height: int = 0
) -> None:
super().__init__(name, storage)
self._width: int = width
self._height: int = height
self._meta_loaded: bool = bool(width and height)

async def _load_meta(self) -> None:
data = await self._storage.open(self.name)

def _extract_meta() -> tuple[int, int]:
with Image.open(data) as image:
return image.size

self._width, self._height = await asyncio.to_thread(_extract_meta)
self._meta_loaded = True

async def get_dimensions(self) -> tuple[int, int]:
"""
Retrieve the dimensions of the image (width and height).

If the image metadata has not been loaded yet, this method asynchronously
loads it from the storage backend before returning the values.

:return: A tuple containing the image width and height in pixels.
:rtype: tuple[int, int]
:raises OSError: If the image file cannot be opened or read from storage.
:raises ValueError: If the image file is not a valid image or dimensions cannot be determined.
"""
if not self._meta_loaded:
await self._load_meta()
return self._width, self._height
85 changes: 84 additions & 1 deletion src/async_storages/integrations/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.types import TypeDecorator, TypeEngine, Unicode

from async_storages.base import BaseStorage, StorageFile
from async_storages import StorageFile, StorageImage
from async_storages.base import BaseStorage


class FileType(TypeDecorator[Any]):
"""
SQLAlchemy column type for representing stored files.

This type integrates with :class:`~async_storages.base.BaseStorage`
to automatically wrap database values (file names) into
:class:`~async_storages.StorageFile` objects when queried.

:param storage: The storage backend used to manage file operations.
:type storage: BaseStorage
:param args: Additional positional arguments passed to ``TypeDecorator``.
:param kwargs: Additional keyword arguments passed to ``TypeDecorator``.
"""

impl: TypeEngine[Any] | type[TypeEngine[Any]] = Unicode
cache_ok: bool | None = True

Expand All @@ -15,6 +29,20 @@ def __init__(self, storage: BaseStorage, *args: Any, **kwargs: Any):

@override
def process_bind_param(self, value: Any, dialect: Dialect) -> str:
"""
Process the Python value before storing it in the database.

Converts :class:`~async_storages.StorageFile` objects
(or similar objects with a ``name`` attribute) into their
string name representation for persistence.

:param value: The Python value being bound to a database column.
:type value: Any
:param dialect: The SQLAlchemy database dialect in use.
:type dialect: Dialect
:return: The serialized string representation of the file name.
:rtype: str
"""
if value is None:
return value
if isinstance(value, str):
Expand All @@ -29,6 +57,61 @@ def process_bind_param(self, value: Any, dialect: Dialect) -> str:
def process_result_value(
self, value: Any | None, dialect: Dialect
) -> StorageFile | None:
"""
Process the database value after fetching from the database.

Converts a stored file name string into a
:class:`~async_storages.StorageFile` instance
associated with the configured storage backend.

:param value: The raw value retrieved from the database.
:type value: Any or None
:param dialect: The SQLAlchemy database dialect in use.
:type dialect: Dialect
:return: A :class:`~async_storages.StorageFile` instance, or ``None``.
:rtype: StorageFile or None
"""
if value is None:
return None
return StorageFile(name=value, storage=self.storage)


class ImageType(FileType):
"""
SQLAlchemy column type for representing stored image files.

This type extends :class:`~.FileType` to automatically wrap
database values (image file names) into
:class:`~async_storages.StorageImage` objects when queried.

It integrates with a configured :class:`~async_storages.base.BaseStorage`
backend to provide convenient access to image operations such as
resizing, thumbnail generation, or metadata retrieval.

:param storage: The storage backend used to manage image file operations.
:type storage: BaseStorage
:param args: Additional positional arguments passed to ``FileType``.
:param kwargs: Additional keyword arguments passed to ``FileType``.
"""

@override
def process_result_value(
self, value: Any | None, dialect: Dialect
) -> StorageFile | None:
"""
Process the database value after fetching from the database.

Converts a stored image file name string into a
:class:`~async_storages.StorageImage` instance associated with
the configured storage backend.

:param value: The raw value retrieved from the database.
:type value: Any or None
:param dialect: The SQLAlchemy database dialect in use.
:type dialect: Dialect
:return: A :class:`~async_storages.StorageImage` instance, or ``None``.
:rtype: StorageImage or None
"""
if value is None:
return None
return StorageImage(name=value, storage=self.storage)
Loading