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
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
# fastapi-cloud-storage
# fastapi-async-storages

A powerful, extensible, and async-ready cloud object storage backend for FastAPI.

> Drop-in, plug-and-play cloud storage for your FastAPI apps; with full async support.\
Inspired by [fastapi-storages](https://github.com/aminalaee/fastapi-storages), built on modern async patterns using [aioboto3](https://github.com/terricain/aioboto3).
> Inspired by [fastapi-storages](https://github.com/aminalaee/fastapi-storages), built on modern async patterns using [aioboto3](https://github.com/terricain/aioboto3).

## Installation

```bash
uv add "fastapi-cloud-storage[s3]"
uv add "fastapi-async-storages[s3]"
```

## Quick Start

1. **Define your async S3 storage**

```py
from cloud_storage import AsyncS3Storage
from async_storages import S3Storage

storage = AsyncS3Storage(
storage = S3Storage(
bucket_name="your-bucket",
endpoint_url="s3.your-cloud.com",
aws_access_key_id="KEY",
Expand All @@ -28,19 +28,19 @@ storage = AsyncS3Storage(
```

2. **Define your SQLAlchemy/SQLModel model**\
Use the provided `AsyncFileType` as the column type:
Use the provided `FileType` as the column type:

```py
from sqlalchemy import Column, Integer
from cloud_storage.integrations.sqlalchemy import AsyncFileType
from async_storages.integrations.sqlalchemy import FileType
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True)
file = Column(AsyncFileType(storage=storage))
file = Column(FileType(storage=storage))
```

3. **Upload files asynchronously before DB commit**\
Expand All @@ -57,14 +57,14 @@ await session.commit()

# fetch from DB
doc = await session.get(Document, doc.id)
assert isinstance(doc.file, AsyncStorageFile)
assert isinstance(doc.file, StorageFile)

url = await doc.file.get_url()
await doc.file.delete()
```

4. **Access files asynchronously**\
When fetching from `DB`, file attribute is an `AsyncStorageFile` with async methods:
When fetching from `DB`, file attribute is an `StorageFile` with async methods:

```py
doc = await session.get(Document, some_id)
Expand All @@ -79,10 +79,10 @@ await doc.file.delete() # delete current file

```py
from fastapi import FastAPI, UploadFile
from cloud_storage import AsyncS3Storage
from async_storages import S3Storage

app = FastAPI(...)
storage = AsyncS3Storage(...)
storage = S3Storage(...)

@app.post("/upload")
async def upload_file(file: UploadFile):
Expand All @@ -92,4 +92,4 @@ async def upload_file(file: UploadFile):

## License

[MIT](LICENSE) © 2025 ^_^ [`@stabldev`](https://github.com/stabldev)
[MIT](LICENSE) © 2025 ^\_^ [`@stabldev`](https://github.com/stabldev)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "fastapi-cloud-storage"
name = "fastapi-async-storages"
version = "0.1.0"
description = "A powerful, extensible, and async-ready cloud object storage backend for FastAPI."
readme = "README.md"
Expand All @@ -23,7 +23,7 @@ dev = [

[tool.uv.build-backend]
module-root = "src"
module-name = "cloud_storage"
module-name = "async_storages"

[tool.pyright]
reportMissingTypeStubs = "none"
Expand Down
5 changes: 5 additions & 0 deletions src/async_storages/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .base import StorageFile
from .s3 import S3Storage

__version__ = "0.1.0"
__all__ = ["StorageFile", "S3Storage"]
8 changes: 4 additions & 4 deletions src/cloud_storage/base.py → src/async_storages/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import BinaryIO


class AsyncBaseStorage:
class BaseStorage:
def get_name(self, name: str) -> str:
raise NotImplementedError()

Expand All @@ -19,10 +19,10 @@ async def delete(self, name: str) -> None:
raise NotImplementedError()


class AsyncStorageFile:
def __init__(self, name: str, storage: AsyncBaseStorage):
class StorageFile:
def __init__(self, name: str, storage: BaseStorage):
self._name: str = name
self._storage: AsyncBaseStorage = storage
self._storage: BaseStorage = storage

@property
def name(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.types import TypeDecorator, TypeEngine, Unicode

from cloud_storage.base import AsyncBaseStorage, AsyncStorageFile
from async_storages.base import BaseStorage, StorageFile


class AsyncFileType(TypeDecorator[Any]):
class FileType(TypeDecorator[Any]):
impl: TypeEngine[Any] | type[TypeEngine[Any]] = Unicode
cache_ok: bool | None = True

def __init__(self, storage: AsyncBaseStorage, *args: Any, **kwargs: Any):
def __init__(self, storage: BaseStorage, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.storage: AsyncBaseStorage = storage
self.storage: BaseStorage = storage

@override
def process_bind_param(self, value: Any, dialect: Dialect) -> str:
Expand All @@ -28,7 +28,7 @@ def process_bind_param(self, value: Any, dialect: Dialect) -> str:
@override
def process_result_value(
self, value: Any | None, dialect: Dialect
) -> AsyncStorageFile | None:
) -> StorageFile | None:
if value is None:
return None
return AsyncStorageFile(name=value, storage=self.storage)
return StorageFile(name=value, storage=self.storage)
File renamed without changes.
8 changes: 4 additions & 4 deletions src/cloud_storage/s3.py → src/async_storages/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
from pathlib import Path
from typing import Any, BinaryIO, override

from cloud_storage.base import AsyncBaseStorage
from cloud_storage.utils import secure_filename
from async_storages.base import BaseStorage
from async_storages.utils import secure_filename

try:
import aioboto3
from botocore.exceptions import ClientError
except ImportError:
raise ImportError(
"'aioboto3' is not installed. Install with 'fastapi-cloud-storage[s3]'."
"'aioboto3' is not installed. Install with 'fastapi-async-storages[s3]'."
)


class AsyncS3Storage(AsyncBaseStorage):
class S3Storage(BaseStorage):
def __init__(
self,
bucket_name: str,
Expand Down
File renamed without changes.
5 changes: 0 additions & 5 deletions src/cloud_storage/__init__.py

This file was deleted.

6 changes: 3 additions & 3 deletions tests/test_integrations/conftest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from typing import Any
import pytest

from cloud_storage import AsyncS3Storage
from async_storages import S3Storage


@pytest.fixture
async def s3_test_storage(s3_test_env: Any) -> AsyncS3Storage:
async def s3_test_storage(s3_test_env: Any) -> S3Storage:
bucket_name, endpoint_without_scheme = s3_test_env

return AsyncS3Storage(
return S3Storage(
bucket_name=bucket_name,
endpoint_url=endpoint_without_scheme,
aws_access_key_id="fake-access-key",
Expand Down
12 changes: 6 additions & 6 deletions tests/test_integrations/test_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
from sqlalchemy.ext.asyncio.session import async_sessionmaker
from sqlalchemy.orm import declarative_base

from cloud_storage import AsyncStorageFile
from cloud_storage.integrations.sqlalchemy import AsyncFileType
from async_storages import StorageFile
from async_storages.integrations.sqlalchemy import FileType

Base = declarative_base()


class Document(Base):
__tablename__: str = "documents"
id: Column[int] = Column(Integer, primary_key=True)
file: Column[str] = Column(AsyncFileType(storage=None)) # pyright: ignore[reportArgumentType]
file: Column[str] = Column(FileType(storage=None)) # pyright: ignore[reportArgumentType]


@pytest.mark.asyncio
Expand Down Expand Up @@ -55,7 +55,7 @@ async def test_sqlalchemy_with_s3(s3_test_storage: Any):
doc = await session.get(Document, doc_id)

# check instance type
assert isinstance(doc.file, AsyncStorageFile)
assert isinstance(doc.file, StorageFile)
assert doc.file.name == f"{file_name}"

# methods should work
Expand Down Expand Up @@ -108,11 +108,11 @@ async def test_sqlalchemy_filetype_none_and_plain_string_with_s3(s3_test_storage
doc_none = await session.get(Document, id_none)
doc_plain = await session.get(Document, id_plain)

# None should stay None (no AsyncStorageFile instance)
# None should stay None (no StorageFile instance)
assert doc_none.file is None

# check instance type
assert isinstance(doc_plain.file, AsyncStorageFile)
assert isinstance(doc_plain.file, StorageFile)
assert doc_plain.file.name == "plain/path/file.txt"

# methods should work
Expand Down
10 changes: 5 additions & 5 deletions tests/test_s3_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
from typing import Any
import pytest

from cloud_storage import AsyncS3Storage
from async_storages import S3Storage


@pytest.mark.asyncio
async def test_s3_storage_methods(s3_test_env: Any):
bucket_name, endpoint_without_scheme = s3_test_env
storage = AsyncS3Storage(
storage = S3Storage(
bucket_name=bucket_name,
endpoint_url=endpoint_without_scheme,
aws_access_key_id="fake-access-key",
Expand Down Expand Up @@ -44,7 +44,7 @@ async def test_s3_storage_methods(s3_test_env: Any):
async def test_s3_storage_querystring_auth(s3_test_env: Any):
bucket_name, endpoint_without_scheme = s3_test_env

storage = AsyncS3Storage(
storage = S3Storage(
bucket_name=bucket_name,
endpoint_url=endpoint_without_scheme,
aws_access_key_id="fake-access-key",
Expand All @@ -65,7 +65,7 @@ async def test_s3_storage_querystring_auth(s3_test_env: Any):
async def test_s3_storage_custom_domain(s3_test_env: Any):
bucket_name, endpoint_without_scheme = s3_test_env

storage = AsyncS3Storage(
storage = S3Storage(
bucket_name=bucket_name,
endpoint_url=endpoint_without_scheme,
aws_access_key_id="fake-access-key",
Expand All @@ -83,7 +83,7 @@ async def test_s3_storage_custom_domain(s3_test_env: Any):

@pytest.mark.asyncio
async def test_get_secure_key_normalization():
storage = AsyncS3Storage(
storage = S3Storage(
bucket_name="fake-bucket",
endpoint_url="fake-endpoint-url",
aws_access_key_id="fake-access-key",
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.