Fixed #23384 -- Allowed overriding part of a dictionary-type setting

This change is needed for upcoming changes where settings might be
grouped in a parent dictionary.
Thanks Tim Graham for the review.
This commit is contained in:
Claude Paroz 2014-08-29 14:54:08 +02:00
parent 05a8cef428
commit 66757fee7e
9 changed files with 123 additions and 5 deletions

View File

@ -12,6 +12,7 @@ import time # Needed for Windows
from django.conf import global_settings from django.conf import global_settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.datastructures import dict_merge
from django.utils.functional import LazyObject, empty from django.utils.functional import LazyObject, empty
from django.utils import six from django.utils import six
@ -77,6 +78,10 @@ class BaseSettings(object):
elif name == "ALLOWED_INCLUDE_ROOTS" and isinstance(value, six.string_types): elif name == "ALLOWED_INCLUDE_ROOTS" and isinstance(value, six.string_types):
raise ValueError("The ALLOWED_INCLUDE_ROOTS setting must be set " raise ValueError("The ALLOWED_INCLUDE_ROOTS setting must be set "
"to a tuple, not a string.") "to a tuple, not a string.")
elif (hasattr(self, name) and name.isupper() and
isinstance(getattr(self, name), dict) and isinstance(value, dict)):
# This allows defining only a partial dict to update a global setting
value = dict_merge(getattr(self, name), value)
object.__setattr__(self, name, value) object.__setattr__(self, name, value)
@ -144,7 +149,7 @@ class UserSettingsHolder(BaseSettings):
from the module specified in default_settings (if possible). from the module specified in default_settings (if possible).
""" """
self.__dict__['_deleted'] = set() self.__dict__['_deleted'] = set()
self.default_settings = default_settings self.__dict__['default_settings'] = default_settings
def __getattr__(self, name): def __getattr__(self, name):
if name in self._deleted: if name in self._deleted:

View File

@ -49,7 +49,7 @@ class MigrationLoader(object):
@classmethod @classmethod
def migrations_module(cls, app_label): def migrations_module(cls, app_label):
if app_label in settings.MIGRATION_MODULES: if settings.MIGRATION_MODULES.get(app_label):
return settings.MIGRATION_MODULES[app_label] return settings.MIGRATION_MODULES[app_label]
else: else:
app_package_name = apps.get_app_config(app_label).name app_package_name = apps.get_app_config(app_label).name

View File

@ -244,6 +244,26 @@ class SortedDict(dict):
self.keyOrder = [] self.keyOrder = []
def dict_merge(a, b):
"""
Utility to recursively merge two dicts, taking care not to overwrite subkeys
(which would happen with dict.update), but keeping existing key including
those from subdictionaries (optionally opted-out if a `_clear_defaults` key
is present).
Thanks Ross McFarland (https://www.xormedia.com/recursively-merge-dictionaries-in-python/)
"""
if b.get('_clear_defaults'):
return copy.deepcopy(b)
result = copy.deepcopy(a)
for key, value in six.iteritems(b):
if key in a and isinstance(result[key], dict):
result[key] = dict_merge(result[key], value)
else:
result[key] = value
return result
class OrderedSet(object): class OrderedSet(object):
""" """
A set which keeps the ordering of the inserted items. A set which keeps the ordering of the inserted items.

View File

@ -530,6 +530,9 @@ Miscellaneous
widget to allow more customization. The undocumented ``url_markup_template`` widget to allow more customization. The undocumented ``url_markup_template``
attribute was removed in favor of ``template_with_initial``. attribute was removed in favor of ``template_with_initial``.
* When a dictionary setting is overridden in user settings, both dictionaries
are merged by default. See :ref:`dictionary-settings`.
.. _deprecated-features-1.8: .. _deprecated-features-1.8:
Features deprecated in 1.8 Features deprecated in 1.8

View File

@ -110,6 +110,32 @@ between the current settings file and Django's default settings.
For more, see the :djadmin:`diffsettings` documentation. For more, see the :djadmin:`diffsettings` documentation.
.. _dictionary-settings:
Overriding dictionary settings
------------------------------
.. versionchanged:: 1.8
When defining a dictionary-type setting which has a non-empty value (see
:setting:`CACHES` for example), you do not have to redefine all its keys. You
can just define the keys differing from the default, and Django will simply
merge your setting value with the default value. For example, if you define
:setting:`CACHES` so::
CACHES = {
'special': {
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
'LOCATION': '127.0.0.1:11211',
}
}
then ``CACHES['default']`` which is set by default in Django's global settings
will still be defined, as well as the new ``'special'`` cache backend.
If you want your setting to completely override the default value, you can add
a ``_clear_defaults`` key with a ``True`` value to the dictionary.
Using settings in Python code Using settings in Python code
============================= =============================

View File

@ -522,6 +522,7 @@ class BaseCacheTests(object):
def _perform_cull_test(self, cull_cache, initial_count, final_count): def _perform_cull_test(self, cull_cache, initial_count, final_count):
# Create initial cache key entries. This will overflow the cache, # Create initial cache key entries. This will overflow the cache,
# causing a cull. # causing a cull.
cull_cache.clear()
for i in range(1, initial_count): for i in range(1, initial_count):
cull_cache.set('cull%d' % i, 'value', 1000) cull_cache.set('cull%d' % i, 'value', 1000)
count = 0 count = 0
@ -918,7 +919,10 @@ class DBCacheTests(BaseCacheTests, TransactionTestCase):
stdout=stdout stdout=stdout
) )
self.assertEqual(stdout.getvalue(), self.assertEqual(stdout.getvalue(),
"Cache table 'test cache table' already exists.\n" * len(settings.CACHES)) "Cache table 'test cache table' already exists.\n" * len([
k for k, v in settings.CACHES.items()
if v['BACKEND']=='django.core.cache.backends.db.DatabaseCache'])
)
def test_createcachetable_with_table_argument(self): def test_createcachetable_with_table_argument(self):
""" """

View File

@ -196,7 +196,7 @@ class ExecutorTests(MigrationTestBase):
@override_settings( @override_settings(
MIGRATION_MODULES={ MIGRATION_MODULES={
"migrations": "migrations.test_migrations_custom_user", "migrations": "migrations.test_migrations_custom_user",
"django.contrib.auth": "django.contrib.auth.migrations", "auth": "django.contrib.auth.migrations",
}, },
AUTH_USER_MODEL="migrations.Author", AUTH_USER_MODEL="migrations.Author",
) )

View File

@ -81,7 +81,10 @@ class LoaderTests(TestCase):
# Ensure we've included unmigrated apps in there too # Ensure we've included unmigrated apps in there too
self.assertIn("basic", project_state.real_apps) self.assertIn("basic", project_state.real_apps)
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_unmigdep"}) @override_settings(MIGRATION_MODULES={
"_clear_defaults": True,
"migrations": "migrations.test_migrations_unmigdep"
})
def test_load_unmigrated_dependency(self): def test_load_unmigrated_dependency(self):
""" """
Makes sure the loader can load migrations with a dependency on an unmigrated app. Makes sure the loader can load migrations with a dependency on an unmigrated app.

View File

@ -273,6 +273,63 @@ class SettingsTests(TestCase):
self.assertRaises(ValueError, setattr, settings, self.assertRaises(ValueError, setattr, settings,
'ALLOWED_INCLUDE_ROOTS', '/var/www/ssi/') 'ALLOWED_INCLUDE_ROOTS', '/var/www/ssi/')
def test_dict_setting(self):
"""
Test that dictionary-type settings can be "complemented", that is existing
setting keys/values are not overriden by user settings, but merged into the
existing dict.
"""
s = LazySettings() # Start with fresh settings from global_settings.py
# Simply overwriting the key
s.configure(CACHES={'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}})
self.assertEqual(s.CACHES['default']['BACKEND'],
'django.core.cache.backends.dummy.DummyCache')
s = LazySettings()
# More complex overwriting
s.configure(CACHES={
'default': {'LOCATION': 'unique-snowflake'},
'temp': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}
})
self.assertDictEqual(s.CACHES, {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-snowflake'
},
'temp': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
}
})
def test_dict_setting_clear_defaults(self):
"""
Test the ability to deactivate the merge feature of dictionary settings.
"""
s = LazySettings()
s.configure(CACHES={
'_clear_defaults': True,
'temp': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}
})
self.assertDictEqual(s.CACHES, {
'_clear_defaults': True,
'temp': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}
})
# Also work on a subkey
s = LazySettings()
s.configure(CACHES={
'default': {
'_clear_defaults': True,
'LOCATION': 'unique-snowflake',
}
})
self.assertDictEqual(s.CACHES, {
'default': {
'_clear_defaults': True,
'LOCATION': 'unique-snowflake',
}
})
class TestComplexSettingOverride(TestCase): class TestComplexSettingOverride(TestCase):
def setUp(self): def setUp(self):