diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d0c3cbf
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..747ffb7
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst
new file mode 100644
index 0000000..7a84689
--- /dev/null
+++ b/docs/source/api_reference.rst
@@ -0,0 +1,8 @@
+API Reference
+=============
+
+.. toctree::
+ :maxdepth: 2
+
+ async_storages
+ async_storages.integrations
diff --git a/docs/source/async_storages.integrations.rst b/docs/source/async_storages.integrations.rst
new file mode 100644
index 0000000..35bd434
--- /dev/null
+++ b/docs/source/async_storages.integrations.rst
@@ -0,0 +1,10 @@
+async_storages.integrations
+=========================
+
+.. autoclass:: async_storages.integrations.sqlalchemy.FileType
+ :exclude-members: cache_ok, impl, process_bind_param, process_result_value
+ :show-inheritance:
+
+.. autoclass:: async_storages.integrations.sqlalchemy.ImageType
+ :exclude-members: process_result_value
+ :show-inheritance:
diff --git a/docs/source/async_storages.rst b/docs/source/async_storages.rst
new file mode 100644
index 0000000..6975e75
--- /dev/null
+++ b/docs/source/async_storages.rst
@@ -0,0 +1,6 @@
+async_storages
+=============
+
+.. automodule:: async_storages
+ :members:
+ :show-inheritance:
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..5e4eb50
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,30 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = "fastapi-async-storages"
+copyright = "2025, stabldev"
+author = "stabldev"
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+ # "qiskit_sphinx_theme",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.napoleon",
+ "sphinx.ext.viewcode",
+]
+
+templates_path = ["_templates"]
+exclude_patterns = []
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "furo"
+html_static_path = ["_static"]
diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst
new file mode 100644
index 0000000..d712b59
--- /dev/null
+++ b/docs/source/contributing.rst
@@ -0,0 +1,53 @@
+Contributing
+============
+
+Thank you for your interest in contributing to ``fastapi-async-storages``!
+We welcome contributions of all kinds; whether code, documentation, bug reports, or ideas.
+
+Contribution Guide
+------------------
+
+How to Contribute
+~~~~~~~~~~~~~~~~~
+1. **Check the roadmap and open issues**
+
+ See what’s planned or in progress to avoid duplication and align your efforts.
+
+2. **Fork the repository and create a feature branch**
+
+ Create your own copy of the project to work on.
+ Use a descriptive name for your branch, e.g., ``feature/async-gcs-backend``.
+
+3. **Write clear, concise code and documentation**
+
+ Follow existing style and patterns. Include tests for new features or bug fixes.
+
+4. **Open a pull request (PR)**
+
+ Provide a detailed description of your changes, related issues, and any testing you performed.
+
+5. **Respond to review feedback**
+
+ We may ask for clarifications, improvements, or changes before merging.
+
+Coding Guidelines
+~~~~~~~~~~~~~~~~~
+- Write asynchronous code following Python’s async/await conventions.
+- Ensure compatibility with FastAPI and `SQLAlchemy `_/`SQLModel `_ async patterns.
+- Add type hints for functions and methods.
+- Document public APIs and complex internal logic clearly.
+- Adhere to `PEP 8 style `_ guidelines for Python code.
+
+Reporting Issues
+~~~~~~~~~~~~~~~~~
+- Use the issue tracker to report bugs, suggest enhancements, or ask questions.
+- Provide clear reproduction steps, error messages, and environment details.
+
+Community Conduct
+~~~~~~~~~~~~~~~~~
+- Be respectful and considerate to all contributors.
+- Follow inclusive language practices.
+- Contributions must comply with the project’s license.
+
+Thank you for helping make ``fastapi-async-storages`` better for everyone!
+We look forward to your contributions and collaboration.
diff --git a/docs/source/faq.rst b/docs/source/faq.rst
new file mode 100644
index 0000000..bf0f2de
--- /dev/null
+++ b/docs/source/faq.rst
@@ -0,0 +1,39 @@
+FAQ
+===
+
+This section answers common questions about ``fastapi-async-storages``, explaining its purpose,
+differences from similar libraries, usage considerations, and extensibility options.
+
+What is `fastapi-async-storages `_?
+---------------------------------------------------------------------------------------
+`fastapi-async-storages `_ is the asynchronous counterpart to `fastapi-storages `_.
+It provides non-blocking storage backends for FastAPI applications using async libraries such as `aioboto3 `_ for S3.
+
+How is it different from `fastapi-storages `_?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+While `fastapi-storages `_ is sync-based and built around traditional blocking I/O,
+`fastapi-async-storages `_ is fully asynchronous.
+This allows your FastAPI app to handle more concurrent requests efficiently, especially during file upload or download operations.
+
+Why upload files before commit?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+`SQLAlchemy `_’s ORM is primarily synchronous, even when used with `AsyncSession `_.
+The model layer and column bindings (including file fields) are not async-aware.
+Therefore, storage operations like uploading or deleting files must happen **before** the database commit.
+
+Does `SQLModel `_ integration work the same way?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Yes. The `SQLModel `_ integration layer internally uses `SQLAlchemy `_’s async engine and sessions.
+You can define :class:`~async_storages.integrations.sqlalchemy.FileType` columns exactly as you would in SQLAlchemy models, following the same upload-before-commit pattern.
+
+Can I use it in synchronous contexts?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+You can, but it’s not recommended. The library is designed around `asyncio `_ to take advantage of FastAPI’s asynchronous execution.
+If your app runs entirely synchronously, stick with `fastapi-storages `_ for simplicity.
+
+Can I extend it with other storage backends?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Yes. The library’s base :class:`~async_storages.base.BaseStorage` class is extensible.
+You can implement new async backends (like local filesystem, Google Cloud Storage, or Azure Blob) by subclassing it and implementing the async methods ``upload``, ``get_url``, and ``delete``.
+
+`fastapi-async-storages `_ aims to stay compatible with existing `fastapi-storages `_ APIs, so extension patterns remain familiar.
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..8acf037
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,86 @@
+fastapi-async-storages
+======================
+
+A powerful, extensible, and async-ready cloud object storage backend for FastAPI.
+
+.. epigraph::
+ Drop-in, plug-and-play cloud storage for your FastAPI apps; with full async support.
+ Inspired by `fastapi-storages `_,
+ built on modern async patterns using `aioboto3 `_.
+
+Features
+--------
+
+* Fully asynchronous storage interface designed for FastAPI applications
+* Async S3 backend powered by `aioboto3 `_
+* `SQLAlchemy `_ and `SQLModel `_ integration
+* Typed and extensible design
+* Supports FastAPI dependency injection
+
+Table of contents
+-----------------
+
+.. toctree::
+ :caption: USER GUIDE
+ :maxdepth: 3
+
+ installation
+ usage
+ api_reference
+
+.. toctree::
+ :maxdepth: 1
+
+ faq
+
+.. toctree::
+ :caption: DEVELOPMENT
+ :maxdepth: 1
+
+ roadmap
+ contributing
+
+Example: FastAPI
+----------------
+
+.. code-block:: python
+
+ from fastapi import FastAPI, UploadFile
+ from sqlalchemy import Column, Integer
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+ from sqlalchemy.orm import sessionmaker, declarative_base
+ from async_storages import S3Storage
+ from async_storages.integrations.sqlalchemy import FileType
+
+ Base = declarative_base()
+
+ app = FastAPI()
+ storage = S3Storage(...)
+ engine = create_async_engine("sqlite+aiosqlite:///test.db", echo=True)
+
+ # create AsyncSession factory
+ AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ class Example(Base):
+ __tablename__ = "example"
+
+ id = Column(Integer, primary_key=True)
+ file = Column(FileType(storage=storage))
+
+ # create tables inside an async context
+ @app.on_event("startup")
+ async def startup():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ @app.post("/upload/")
+ async def create_upload_file(file: UploadFile):
+ file_name = f"uploads/{file.filename}"
+ # upload before commit due to the sqlalchemy binding being sync
+ await storage.upload(file.file, file_name)
+
+ example = Example(file=file)
+ async with AsyncSessionLocal() as session:
+ session.add(example)
+ await session.commit()
+ return {"filename": example.file.name}
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
new file mode 100644
index 0000000..20b0f0a
--- /dev/null
+++ b/docs/source/installation.rst
@@ -0,0 +1,49 @@
+Installation
+============
+
+This document explains how to install the ``fastapi-async-storages`` package.
+Note that you can use any package manager you prefer, such as `uv`, `pip`, or others.
+
+Prerequisites
+-------------
+- Python 3.12 or higher
+- A package manager like `uv` installed (see https://astral.sh/uv/ for more info)
+
+Installation with uv
+--------------------
+
+You can install the package using `uv`:
+
+.. code-block:: bash
+
+ uv add fastapi-async-storages
+ # for s3 support:
+ uv add fastapi-async-storages[s3]
+
+.. warning::
+
+ If you need **image support** (using :class:`~async_storages.integrations.sqlalchemy.ImageType` or :class:`~async_storages.StorageImage`),
+ make sure :code:`Pillow` library is installed.
+
+ .. code-block:: bash
+
+ uv add Pillow
+ # or uv pip install Pillow
+
+Or, to install from source:
+
+.. code-block:: bash
+
+ git clone https://github.com/stabldev/fastapi-async-storages.git
+ cd fastapi-async-storages
+ uv sync --all-extras
+
+Verify installation
+-------------------
+
+You can verify the package installation by importing it:
+
+.. code-block:: python
+
+ import async_storages
+ print(async_storages.__version__)
diff --git a/docs/source/roadmap.rst b/docs/source/roadmap.rst
new file mode 100644
index 0000000..d4e352b
--- /dev/null
+++ b/docs/source/roadmap.rst
@@ -0,0 +1,36 @@
+Roadmap
+=======
+
+This list shows the current and planned features of ``fastapi-async-storages``;
+checked items are implemented, unchecked are upcoming.
+
+Completed & Planned Features
+-------------------------------
+
+Storage Backends
+~~~~~~~~~~~~~~~~
+- [x] Async S3 backend powered by `aioboto3 `_
+- [x] Compatibility with `MinIO `_ and other S3-compatible services
+- [ ] Async Local Filesystem backend using `aiofiles `_
+- [ ] Async Google Cloud Storage (GCS) backend using `google-cloud-storage `_
+- [ ] Async Azure Blob Storage integration with `azure-sdk-for-python `_
+
+Framework Integrations
+~~~~~~~~~~~~~~~~~~~~~~
+- [x] `SQLAlchemy ORM `_ async integration
+- [ ] `Tortoise ORM `_ async integration
+- [ ] `Peewee ORM `_ async integration
+
+Features & Enhancements
+~~~~~~~~~~~~~~~~~~~~~~~
+- [x] Presigned URL generation for uploads and downloads
+- [ ] Async streaming support for large files
+- [ ] Bulk async upload and delete operations
+- [ ] Progress tracking with hooks or callbacks
+- [ ] Automatic cleanup utilities for orphaned/expired files
+
+DX & Documentation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+- [x] Core documentation and usage examples
+- [x] Testing utilities and mocks for async storage testing
+- [ ] Expanded real-world usage and best practices
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
new file mode 100644
index 0000000..e55beb3
--- /dev/null
+++ b/docs/source/usage.rst
@@ -0,0 +1,132 @@
+Usage
+=====
+
+This section provides practical examples and guidance on using ``fastapi-async-storages``
+to handle asynchronous file storage in your FastAPI applications.
+
+Working with storages
+---------------------
+
+Often in projects, you want to get input file in the API and store it somewhere.
+The `fastapi-async-storages` simplifies the process to store and retrieve the files in a re-usable manner.
+
+Available storage backends:
+
+* :class:`~async_storages.S3Storage`: To store file objects in Amazon S3 or any s3-compatible object storage.
+
+S3Storage
+~~~~~~~~~
+
+The :class:`~async_storages.S3Storage` class provides an asynchronous interface for interacting
+with S3-compatible object storages such as AWS S3 or MinIO.
+
+Now let's see a minimal example of using :class:`~async_storages.S3Storage` in action:
+
+.. code-block:: python
+
+ from io import BytesIO
+ from async_storages import S3Storage
+
+ storage = S3Storage(
+ bucket_name="my-bucket",
+ endpoint_url="localhost:9000", # no protocol (http/https)
+ aws_access_key_id="fake-access-key",
+ aws_secret_access_key="fake-secret-key",
+ use_ssl=False,
+ )
+
+ async def main():
+ file_name = "uploads/example.txt"
+ file_obj = BytesIO(b"hello world")
+
+ # upload a file
+ await storage.upload(file_obj, file_name)
+ # get file URL
+ url = await storage.get_url(file_name)
+ print(url)
+ # get file size
+ size = await storage.get_size(file_name)
+ # delete file
+ await storage.delete(file_name)
+
+.. warning::
+
+ You should never hard-code credentials like `aws_access_key_id` and `aws_secret_access_key` in the code.
+ Instead, you can read values from environment variables or read from your app settings.
+
+Working with ORM extensions
+---------------------------
+
+The example you saw was useful, but **fastapi-async-storages** has ORM integrations
+which makes storing and serving the files easier.
+
+Support ORM include:
+
+* `SQLAlchemy `_
+* `SQLModel `_
+
+SQLAlchemy
+~~~~~~~~~~
+
+You can use custom :code:`SQLAlchemy` types from :code:`fastapi-async-storages` for this.
+
+Supported types include:
+
+* :class:`~async_storages.integrations.sqlalchemy.FileType`: Type that returns a :class:`~async_storages.StorageFile` instance for file fields.
+* :class:`~async_storages.integrations.sqlalchemy.ImageType`: Type that returns a :class:`~async_storages.StorageImage` instance for image fields.
+
+
+Let's see an example:
+
+.. code-block:: python
+
+ from sqlalchemy import Column, Integer
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+ from sqlalchemy.ext.asyncio.session import async_sessionmaker
+ from sqlalchemy.orm import declarative_base
+
+ from async_storages import S3Storage
+ from async_storages.integrations.sqlalchemy import FileType, ImageType
+
+ Base = declarative_base()
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+ async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
+
+ storage = S3Storage(
+ bucket_name="my-bucket",
+ endpoint_url="localhost:9000",
+ aws_access_key_id="fake-access-key",
+ aws_secret_access_key="fake-secret-key",
+ use_ssl=False,
+ )
+
+ class Document(Base):
+ __tablename__ = "documents"
+
+ id = Column(Integer, primary_key=True)
+ file = Column(FileType(storage=storage))
+ image = Column(ImageType(storage=storage))
+
+ async def main():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ # create an in-memory image
+ img_buf = BytesIO()
+ Image.new("RGB", (32, 16), color=(255, 0, 0)).save(img_buf, format="PNG")
+ img_buf.seek(0)
+
+ # upload and link file and image
+ img_name await storage.upload(img_buf, "uploads/test-image.png")
+ file_name = await storage.upload(BytesIO(b"hello world"), "uploads/test.txt")
+
+ async with async_session() as session:
+ doc = Document(file=file_name, image=img_name)
+ session.add(doc)
+ await session.commit()
+
+ doc = await session.get(Document, doc.id)
+ url = await doc.file.get_url()
+ print(url)
+ width, height = await doc.image.get_dimensions()
+ print(f"Dimensions: {width}x{height}")
diff --git a/pyproject.toml b/pyproject.toml
index b78fb40..9c82f4e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,10 @@ dev = [
"pytest-asyncio>=1.2.0",
"sqlalchemy>=2.0.44",
]
+docs = [
+ "furo>=2025.9.25",
+ "sphinx>=8.2.3",
+]
[tool.uv.build-backend]
module-root = "src"
diff --git a/src/async_storages/base.py b/src/async_storages/base.py
index c05d259..959bdff 100644
--- a/src/async_storages/base.py
+++ b/src/async_storages/base.py
@@ -7,85 +7,28 @@
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:
- """
- 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:
- """
- 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:
- """
- 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:
- """
- 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:
- """
- Delete the file from the storage backend.
-
- :param name: Original file name or identifier.
- :type name: str
- :return: None
- :rtype: None
- """
pass
diff --git a/src/async_storages/integrations/sqlalchemy.py b/src/async_storages/integrations/sqlalchemy.py
index a0a36a1..8ecd6be 100644
--- a/src/async_storages/integrations/sqlalchemy.py
+++ b/src/async_storages/integrations/sqlalchemy.py
@@ -29,20 +29,6 @@ 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):
@@ -57,20 +43,6 @@ 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)
@@ -98,20 +70,6 @@ class ImageType(FileType):
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)
diff --git a/uv.lock b/uv.lock
index f4e8061..8b97c76 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,6 +2,18 @@ version = 1
revision = 3
requires-python = ">=3.12"
+[[package]]
+name = "accessible-pygments"
+version = "0.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
+]
+
[[package]]
name = "aioboto3"
version = "15.4.0"
@@ -175,6 +187,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
+[[package]]
+name = "alabaster"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
+]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -230,6 +251,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/69/b417833a8926fa5491e5346d7c233bf7d8a9b12ba1f4ef41ccea2494000c/aws_xray_sdk-2.14.0-py2.py3-none-any.whl", hash = "sha256:cfbe6feea3d26613a2a869d14c9246a844285c97087ad8f296f901633554ad94", size = 101922, upload-time = "2024-06-04T22:12:25.729Z" },
]
+[[package]]
+name = "babel"
+version = "2.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
+]
+
[[package]]
name = "blinker"
version = "1.9.0"
@@ -511,6 +554,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]
+[[package]]
+name = "docutils"
+version = "0.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
+]
+
[[package]]
name = "fastapi-async-storages"
version = "0.1.0"
@@ -530,6 +582,10 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "sqlalchemy" },
]
+docs = [
+ { name = "furo" },
+ { name = "sphinx" },
+]
[package.metadata]
requires-dist = [{ name = "aioboto3", marker = "extra == 's3'", specifier = ">=15.4.0" }]
@@ -544,6 +600,10 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "sqlalchemy", specifier = ">=2.0.44" },
]
+docs = [
+ { name = "furo", specifier = ">=2025.9.25" },
+ { name = "sphinx", specifier = ">=8.2.3" },
+]
[[package]]
name = "flask"
@@ -664,6 +724,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "furo"
+version = "2025.9.25"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "accessible-pygments" },
+ { name = "beautifulsoup4" },
+ { name = "pygments" },
+ { name = "sphinx" },
+ { name = "sphinx-basic-ng" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4e/29/ff3b83a1ffce74676043ab3e7540d398e0b1ce7660917a00d7c4958b93da/furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98", size = 1662007, upload-time = "2025-09-25T21:37:19.221Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" },
+]
+
[[package]]
name = "graphql-core"
version = "3.2.6"
@@ -715,6 +791,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1682,6 +1767,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
]
+[[package]]
+name = "roman-numerals-py"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" },
+]
+
[[package]]
name = "rpds-py"
version = "0.28.0"
@@ -1793,6 +1887,118 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
+[[package]]
+name = "snowballstemmer"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
+]
+
+[[package]]
+name = "sphinx"
+version = "8.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals-py" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" },
+]
+
+[[package]]
+name = "sphinx-basic-ng"
+version = "1.0.0b2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.44"