Skip to content

Custom File Storage

HyperDjango provides a pluggable file storage system for uploading, retrieving, and managing files. The built-in backends cover local filesystem and in-memory (testing) storage. You can write custom backends for S3, GCS, or any other storage system by implementing the Storage interface.

Built-in Backends

FileSystemStorage

The default backend. Stores files on the local filesystem with atomic writes (write to temp file, then os.replace).

from hyperdjango.storage import FileSystemStorage

storage = FileSystemStorage(location="/var/uploads", base_url="/media/")

# Save a file (returns final name, may append _1, _2 to avoid conflicts)
name = await storage.save("photos/avatar.jpg", image_bytes)

# Get the URL
url = storage.url(name)  # "/media/photos/avatar.jpg"

# Read file contents
data = await storage.open(name)  # bytes

# Check existence
exists = await storage.exists(name)  # True

# Get file size
size = await storage.size(name)  # int (bytes)

# List directory contents
dirs, files = await storage.listdir("photos/")

# Delete
await storage.delete(name)

Path traversal is prevented -- .. is stripped and leading / is removed from all file names.

MemoryStorage

An in-memory backend for tests. Thread-safe. Files are stored in a dict and lost when the process exits.

from hyperdjango.storage import MemoryStorage

storage = MemoryStorage(base_url="/media/")

name = await storage.save("test.txt", b"hello")
data = await storage.open(name)  # b"hello"
exists = await storage.exists(name)  # True
await storage.delete(name)

Global Storage

Use get_storage() to access the app-configured storage backend:

from hyperdjango.storage import get_storage

storage = get_storage()
name = await storage.save("uploads/report.pdf", pdf_bytes)

The Storage Interface

Every storage backend must subclass Storage and implement these async methods:

Method Signature Description
save async save(name: str, content: bytes) -> str Save content. Return the final file name (may be modified to avoid conflicts).
open async open(name: str) -> bytes Read and return file contents. Raise FileNotFoundError if missing.
delete async delete(name: str) Delete a file.
exists async exists(name: str) -> bool Return True if the file exists.
url url(name: str) -> str Return the public URL for a file. Synchronous.
size async size(name: str) -> int Return file size in bytes.
listdir async listdir(path: str) -> tuple[list[str], list[str]] Return (directories, files) at the given path.
get_available_name get_available_name(name: str) -> str Return a filename that does not conflict with existing files. Synchronous.

Writing a Custom Backend

Here is a pattern for an S3-compatible storage backend:

from dataclasses import dataclass, field

import boto3

from hyperdjango.storage import Storage


@dataclass
class S3Storage(Storage):
    """S3-compatible file storage backend."""

    bucket_name: str = "my-bucket"
    region: str = "us-east-1"
    base_url: str = ""
    prefix: str = ""
    _client: object = field(init=False, repr=False, default=None)

    def __post_init__(self):
        self._client = boto3.client("s3", region_name=self.region)
        if not self.base_url:
            self.base_url = f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/"

    def _key(self, name: str) -> str:
        """Build the full S3 object key."""
        name = name.replace("..", "").lstrip("/")
        if self.prefix:
            return f"{self.prefix.rstrip('/')}/{name}"
        return name

    async def save(self, name: str, content: bytes) -> str:
        key = self._key(name)
        self._client.put_object(Bucket=self.bucket_name, Key=key, Body=content)
        return name

    async def open(self, name: str) -> bytes:
        key = self._key(name)
        try:
            response = self._client.get_object(Bucket=self.bucket_name, Key=key)
            return response["Body"].read()
        except self._client.exceptions.NoSuchKey:
            raise FileNotFoundError(f"File not found: {name}")

    async def delete(self, name: str):
        key = self._key(name)
        self._client.delete_object(Bucket=self.bucket_name, Key=key)

    async def exists(self, name: str) -> bool:
        key = self._key(name)
        try:
            self._client.head_object(Bucket=self.bucket_name, Key=key)
            return True
        except Exception:
            return False

    def url(self, name: str) -> str:
        return f"{self.base_url}{self._key(name)}"

    async def size(self, name: str) -> int:
        key = self._key(name)
        response = self._client.head_object(Bucket=self.bucket_name, Key=key)
        return response["ContentLength"]

Integration with FileField / ImageField

Models use FileField and ImageField to manage file uploads. The storage backend is used automatically:

from hyperdjango.models import Model, Field

class Document(Model):
    title: str = Field()
    file: str = Field(file_field=True, upload_to="documents/")

class Photo(Model):
    caption: str = Field()
    image: str = Field(image_field=True, upload_to="photos/", max_size=5_000_000)

When saving a model with a file field, the file is stored via the configured storage backend and the field value is set to the storage path. The upload_to prefix is prepended to the filename.

Selecting a Backend

Set the storage backend at the app level:

from hyperdjango import HyperApp
from hyperdjango.storage import FileSystemStorage, configure_storage

app = HyperApp("myapp")

# Use filesystem storage (default)
configure_storage(FileSystemStorage(location="./uploads", base_url="/media/"))

# Or use S3 in production
# configure_storage(S3Storage(bucket_name="prod-uploads", region="us-west-2"))

# Or use MemoryStorage in tests
# configure_storage(MemoryStorage())

See Also

  • Files -- FileField and ImageField on models
  • Static Files -- serving CSS, JS, and images with caching
  • Testing -- MemoryStorage for test isolation