Skip to content

Commit

Permalink
Fixed #26029 -- Allowed configuring custom file storage backends.
Browse files Browse the repository at this point in the history
  • Loading branch information
jwygoda authored and felixxm committed Jan 12, 2023
1 parent d02a9f0 commit 1ec3f09
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 11 deletions.
2 changes: 2 additions & 0 deletions django/conf/global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ def gettext_noop(s):
# Default file storage mechanism that holds media.
DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"

STORAGES = {}

# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/var/www/example.com/media/"
MEDIA_ROOT = ""
Expand Down
5 changes: 5 additions & 0 deletions django/core/files/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from .base import Storage
from .filesystem import FileSystemStorage
from .handler import InvalidStorageError, StorageHandler
from .memory import InMemoryStorage

__all__ = (
Expand All @@ -13,6 +14,9 @@
"DefaultStorage",
"default_storage",
"get_storage_class",
"InvalidStorageError",
"StorageHandler",
"storages",
)


Expand All @@ -25,4 +29,5 @@ def _setup(self):
self._wrapped = get_storage_class()()


storages = StorageHandler()
default_storage = DefaultStorage()
46 changes: 46 additions & 0 deletions django/core/files/storage/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.functional import cached_property
from django.utils.module_loading import import_string


class InvalidStorageError(ImproperlyConfigured):
pass


class StorageHandler:
def __init__(self, backends=None):
# backends is an optional dict of storage backend definitions
# (structured like settings.STORAGES).
self._backends = backends
self._storages = {}

@cached_property
def backends(self):
if self._backends is None:
self._backends = settings.STORAGES.copy()
return self._backends

def __getitem__(self, alias):
try:
return self._storages[alias]
except KeyError:
try:
params = self.backends[alias]
except KeyError:
raise InvalidStorageError(
f"Could not find config for '{alias}' in settings.STORAGES."
)
storage = self.create_storage(params)
self._storages[alias] = storage
return storage

def create_storage(self, params):
params = params.copy()
backend = params.pop("BACKEND")
options = params.pop("OPTIONS", {})
try:
storage_cls = import_string(backend)
except ImportError as e:
raise InvalidStorageError(f"Could not find backend {backend!r}: {e}") from e
return storage_cls(**options)
17 changes: 17 additions & 0 deletions django/test/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ def reset_template_engines(*, setting, **kwargs):
get_default_renderer.cache_clear()


@receiver(setting_changed)
def storages_changed(*, setting, **kwargs):
from django.core.files.storage import storages

if setting in (
"STORAGES",
"STATIC_ROOT",
"STATIC_URL",
):
try:
del storages.backends
except AttributeError:
pass
storages._backends = None
storages._storages = {}


@receiver(setting_changed)
def clear_serializers_cache(*, setting, **kwargs):
if setting == "SERIALIZATION_MODULES":
Expand Down
20 changes: 20 additions & 0 deletions docs/howto/custom-file-storage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,23 @@ free unique filename cannot be found, a :exc:`SuspiciousFileOperation

If a file with ``name`` already exists, ``get_alternative_name()`` is called to
obtain an alternative name.

.. _using-custom-storage-engine:

Use your custom storage engine
==============================

.. versionadded:: 4.2

The first step to using your custom storage with Django is to tell Django about
the file storage backend you'll be using. This is done using the
:setting:`STORAGES` setting. This setting maps storage aliases, which are a way
to refer to a specific storage throughout Django, to a dictionary of settings
for that specific storage backend. The settings in the inner dictionaries are
described fully in the :setting:`STORAGES` documentation.

Storages are then accessed by alias from from the
:data:`django.core.files.storage.storages` dictionary::

from django.core.files.storage import storages
example_storage = storages["example"]
1 change: 1 addition & 0 deletions docs/ref/contrib/staticfiles.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Settings
See :ref:`staticfiles settings <settings-staticfiles>` for details on the
following settings:

* :setting:`STORAGES`
* :setting:`STATIC_ROOT`
* :setting:`STATIC_URL`
* :setting:`STATICFILES_DIRS`
Expand Down
6 changes: 6 additions & 0 deletions docs/ref/files/storage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ Getting the default storage class

Django provides convenient ways to access the default storage class:

.. data:: storages

.. versionadded:: 4.2

Storage instances as defined by :setting:`STORAGES`.

.. class:: DefaultStorage

:class:`~django.core.files.storage.DefaultStorage` provides
Expand Down
38 changes: 38 additions & 0 deletions docs/ref/settings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2606,6 +2606,43 @@ Silenced checks will not be output to the console.

See also the :doc:`/ref/checks` documentation.

.. setting:: STORAGES

``STORAGES``
------------

.. versionadded:: 4.2

Default::

{}

A dictionary containing the settings for all storages to be used with Django.
It is a nested dictionary whose contents map a storage alias to a dictionary
containing the options for an individual storage.

Storages can have any alias you choose.

The following is an example ``settings.py`` snippet defining a custom file
storage called ``example``::

STORAGES = {
# ...
"example": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": "/example",
"base_url": "/example/",
},
},
}

``OPTIONS`` are passed to the ``BACKEND`` on initialization in ``**kwargs``.

A ready-to-use instance of the storage backends can be retrieved from
:data:`django.core.files.storage.storages`. Use a key corresponding to the
backend definition in :setting:`STORAGES`.

.. setting:: TEMPLATES

``TEMPLATES``
Expand Down Expand Up @@ -3663,6 +3700,7 @@ File uploads
* :setting:`FILE_UPLOAD_TEMP_DIR`
* :setting:`MEDIA_ROOT`
* :setting:`MEDIA_URL`
* :setting:`STORAGES`

Forms
-----
Expand Down
6 changes: 6 additions & 0 deletions docs/releases/4.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ In-memory file storage
The new ``django.core.files.storage.InMemoryStorage`` class provides a
non-persistent storage useful for speeding up tests by avoiding disk access.

Custom file storages
--------------------

The new :setting:`STORAGES` setting allows configuring multiple custom file
storage backends.

Minor features
--------------

Expand Down
12 changes: 12 additions & 0 deletions docs/topics/files.txt
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,15 @@ For example::

class MyModel(models.Model):
my_file = models.FileField(storage=select_storage)

In order to set a storage defined in the :setting:`STORAGES` setting you can
use a lambda function::

from django.core.files.storage import storages

class MyModel(models.Model):
upload = models.FileField(storage=lambda: storages["custom_storage"])

.. versionchanged:: 4.2

Support for ``storages`` was added.
19 changes: 10 additions & 9 deletions docs/topics/testing/tools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1441,15 +1441,16 @@ when settings are changed.

Django itself uses this signal to reset various data:

================================ ========================
Overridden settings Data reset
================================ ========================
USE_TZ, TIME_ZONE Databases timezone
TEMPLATES Template engines
SERIALIZATION_MODULES Serializers cache
LOCALE_PATHS, LANGUAGE_CODE Default translation and loaded translations
MEDIA_ROOT, DEFAULT_FILE_STORAGE Default file storage
================================ ========================
================================= ========================
Overridden settings Data reset
================================= ========================
USE_TZ, TIME_ZONE Databases timezone
TEMPLATES Template engines
SERIALIZATION_MODULES Serializers cache
LOCALE_PATHS, LANGUAGE_CODE Default translation and loaded translations
MEDIA_ROOT, DEFAULT_FILE_STORAGE Default file storage
STATIC_ROOT, STATIC_URL, STORAGES Storages configuration
================================= ========================

Isolating apps
--------------
Expand Down
48 changes: 46 additions & 2 deletions tests/file_storage/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@
from django.core.cache import cache
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.base import ContentFile, File
from django.core.files.storage import FileSystemStorage
from django.core.files.storage import FileSystemStorage, InvalidStorageError
from django.core.files.storage import Storage as BaseStorage
from django.core.files.storage import default_storage, get_storage_class
from django.core.files.storage import (
StorageHandler,
default_storage,
get_storage_class,
storages,
)
from django.core.files.uploadedfile import (
InMemoryUploadedFile,
SimpleUploadedFile,
Expand Down Expand Up @@ -1157,3 +1162,42 @@ def test_urllib_request_urlopen(self):
remote_file = urlopen(self.live_server_url + "/")
with self.storage.open(stored_filename) as stored_file:
self.assertEqual(stored_file.read(), remote_file.read())


class StorageHandlerTests(SimpleTestCase):
@override_settings(
STORAGES={
"custom_storage": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
}
)
def test_same_instance(self):
cache1 = storages["custom_storage"]
cache2 = storages["custom_storage"]
self.assertIs(cache1, cache2)

def test_defaults(self):
storages = StorageHandler()
self.assertEqual(storages.backends, {})

def test_nonexistent_alias(self):
msg = "Could not find config for 'nonexistent' in settings.STORAGES."
storages = StorageHandler()
with self.assertRaisesMessage(InvalidStorageError, msg):
storages["nonexistent"]

def test_nonexistent_backend(self):
test_storages = StorageHandler(
{
"invalid_backend": {
"BACKEND": "django.nonexistent.NonexistentBackend",
},
}
)
msg = (
"Could not find backend 'django.nonexistent.NonexistentBackend': "
"No module named 'django.nonexistent'"
)
with self.assertRaisesMessage(InvalidStorageError, msg):
test_storages["invalid_backend"]

0 comments on commit 1ec3f09

Please sign in to comment.