Skip to content
Open
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
17 changes: 14 additions & 3 deletions readthedocs/projects/tasks/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import structlog
from django.conf import settings
from django.utils.module_loading import import_string
from django.core.files.storage import storages

from readthedocs.doc_builder.exceptions import BuildAppError
from readthedocs.projects.models import Feature
Expand Down Expand Up @@ -53,13 +53,24 @@ def _get_storage_class(storage_type: StorageType):
raise ValueError("Invalid storage type")


def _get_storage_backend_class(alias: str):
"""
Get the storage backend class for a given alias from STORAGES setting.

This returns the class, not an instance, so it can be instantiated
with custom kwargs (e.g., per-build credentials).
"""
storage = storages[alias]
return type(storage)
Comment on lines +63 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this can be done with

import_string(storages.backends[alias]['BACKEND'])

since there is no need for an instance

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we could use import_string but since the class is already imported, calling type over the instance makes more sense to me than re-importing.



def _get_build_media_storage_class():
"""
Get a storage class to use for syncing build artifacts.

This is done in a separate method to make it easier to mock in tests.
"""
return import_string(settings.RTD_BUILD_MEDIA_STORAGE)
return _get_storage_backend_class("build-media")


def _get_build_tools_storage_class():
Expand All @@ -68,7 +79,7 @@ def _get_build_tools_storage_class():

This is done in a separate method to make it easier to mock in tests.
"""
return import_string(settings.RTD_BUILD_TOOLS_STORAGE)
return _get_storage_backend_class("build-tools")


def _get_s3_scoped_credentials(*, project, build_id, api_client, storage_type: StorageType):
Expand Down
7 changes: 2 additions & 5 deletions readthedocs/proxito/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

import django_dynamic_fixture as fixture
import pytest
from django.conf import settings
from django.contrib.auth.models import User
from readthedocs.storage import get_storage_class
from django.core.files.storage import storages
from django.test import TestCase

from readthedocs.builds.constants import LATEST
Expand All @@ -19,9 +18,7 @@ def setUp(self):
# Re-initialize storage
# Various tests override either this setting or various aspects of the storage engine
# By resetting it every test case, we avoid this caching (which is a huge benefit in prod)
serve.build_media_storage = get_storage_class(
settings.RTD_BUILD_MEDIA_STORAGE
)()
serve.build_media_storage = storages["build-media"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the comment, this instance is also probably cached and will make some tests fail.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that all tests are passing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we remove this line completely? Seems Django is smart enough and it's not required anymore?

Suggested change
serve.build_media_storage = storages["build-media"]


self.eric = fixture.get(User, username="eric")
self.eric.set_password("eric")
Expand Down
7 changes: 4 additions & 3 deletions readthedocs/rtd_tests/tests/test_imported_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
from unittest import mock

import pytest
from django.conf import settings
from readthedocs.storage import get_storage_class
from django.core.files.storage import storages
from django.test import TestCase
from django.test.utils import override_settings
from django_dynamic_fixture import get
Expand All @@ -21,7 +20,9 @@
@pytest.mark.search
@override_settings(ELASTICSEARCH_DSL_AUTOSYNC=True)
class ImportedFileTests(TestCase):
storage = get_storage_class(settings.RTD_BUILD_MEDIA_STORAGE)()
@property
def storage(self):
return storages["build-media"]

def setUp(self):
self.project = get(Project)
Expand Down
17 changes: 15 additions & 2 deletions readthedocs/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1192,16 +1192,29 @@ def GITHUB_APP_CLIENT_ID(self):
@property
def STORAGES(self):
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
# https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES
return {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "readthedocs.storage.s3_storage.S3StaticStorage"
"BACKEND": "readthedocs.storage.s3_storage.S3StaticStorage",
},
"build-media": {
"BACKEND": self.RTD_BUILD_MEDIA_STORAGE,
},
"build-commands": {
"BACKEND": self.RTD_BUILD_COMMANDS_STORAGE,
},
"build-tools": {
"BACKEND": self.RTD_BUILD_TOOLS_STORAGE,
},
"usercontent": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": Path(self.MEDIA_ROOT) / "usercontent",
"allow_overwrite": True,
}
},
},
}

Expand Down
14 changes: 13 additions & 1 deletion readthedocs/settings/docker_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,20 @@ def SOCIALACCOUNT_PROVIDERS(self):
@property
def STORAGES(self):
return {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "readthedocs.storage.s3_storage.S3StaticStorage"
"BACKEND": "readthedocs.storage.s3_storage.S3StaticStorage",
},
"build-media": {
"BACKEND": self.RTD_BUILD_MEDIA_STORAGE,
},
"build-commands": {
"BACKEND": self.RTD_BUILD_COMMANDS_STORAGE,
},
"build-tools": {
"BACKEND": self.RTD_BUILD_TOOLS_STORAGE,
},
"usercontent": {
"BACKEND": "storages.backends.s3.S3Storage",
Expand Down
9 changes: 9 additions & 0 deletions readthedocs/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ def STORAGES(self):
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
"build-media": {
"BACKEND": self.RTD_BUILD_MEDIA_STORAGE,
},
"build-commands": {
"BACKEND": self.RTD_BUILD_COMMANDS_STORAGE,
},
"build-tools": {
"BACKEND": self.RTD_BUILD_TOOLS_STORAGE,
},
"usercontent": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
Expand Down
20 changes: 5 additions & 15 deletions readthedocs/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,28 @@
so doing those upfront improves performance.
"""

from django.conf import settings
from django.core.files.storage import storages
from django.utils.functional import LazyObject
from django.utils.module_loading import import_string


# Borrowed from Django 4.2 since it was deprecrated and removed in 5.2
# NOTE: we can use settings.STORAGES for our own storages as well if we want to use the standards.
#
# https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STORAGES)
# https://github.com/django/django/blob/4.2/django/core/files/storage/__init__.py#L31
def get_storage_class(import_path=None):
return import_string(import_path or settings.DEFAULT_FILE_STORAGE)


class ConfiguredBuildMediaStorage(LazyObject):
def _setup(self):
self._wrapped = get_storage_class(settings.RTD_BUILD_MEDIA_STORAGE)()
self._wrapped = storages["build-media"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these were made in order to lazy instantiate the storage object. storages[alias] does that by default, probably? We should check, we don't need these wrappers otherwise.

Copy link
Member

@humitos humitos Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm, I'm not sure. The documentation doesn't say too much about lazyness.

I asked AI about this and it says that storage[alias] is not lazy by default and we should do what we are currently doing if we want it to be lazy.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.



class ConfiguredBuildCommandsStorage(LazyObject):
def _setup(self):
self._wrapped = get_storage_class(settings.RTD_BUILD_COMMANDS_STORAGE)()
self._wrapped = storages["build-commands"]


class ConfiguredBuildToolsStorage(LazyObject):
def _setup(self):
self._wrapped = get_storage_class(settings.RTD_BUILD_TOOLS_STORAGE)()
self._wrapped = storages["build-tools"]


class ConfiguredStaticStorage(LazyObject):
def _setup(self):
self._wrapped = get_storage_class(settings.RTD_STATICFILES_STORAGE)()
self._wrapped = storages["staticfiles"]


build_media_storage = ConfiguredBuildMediaStorage()
Expand Down