diff --git a/README.rst b/README.rst index de714b7..62c235b 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,7 @@ of the appropriate starter functions, e.g. a typical **manage.py** using django-configurations would look like this: .. code-block:: python + :emphasize-lines: 10 #!/usr/bin/env python @@ -72,13 +73,14 @@ django-configurations would look like this: execute_from_command_line(sys.argv) -Notice in line 9 we don't use the common tool +Notice in line 10 we don't use the common tool ``django.core.management.execute_from_command_line`` but instead ``configurations.management.execute_from_command_line``. The same applies to your **wsgi.py** file, e.g.: .. code-block:: python + :emphasize-lines: 6 import os @@ -95,16 +97,26 @@ function but instead ``configurations.wsgi.get_wsgi_application``. That's it! You can now use your project with ``manage.py`` and your favorite WSGI enabled server. -**Alternatively** you can use a special Django project template that is a copy -of the one included in Django 1.5.x. The following example assumes you're using -pip_ to install dependencies.:: +Project templates +----------------- + +You can use a special Django project template that is a copy of the one +included in Django 1.5.x. The following examples assumes you're using pip_ +to install packages. + +First install Django and django-configurations:: - # first install Django and django-configurations pip install -r https://raw.github.com/jezdez/django-configurations/templates/1.5.x/requirements.txt - # then create your new Django project with the provided template + +Then create your new Django project with the provided template:: + django-admin.py startproject mysite -v2 --template https://github.com/jezdez/django-configurations/archive/templates/1.5.x.zip Now you have a default Django 1.5.x project in the ``mysite`` directory that uses django-configurations. +See the repository of the template for more information: + + https://github.com/jezdez/django-configurations/tree/templates/1.5.x + .. _pip: http://pip-installer.org/ diff --git a/configurations/base.py b/configurations/base.py index ffbc3bf..9b6b6c7 100644 --- a/configurations/base.py +++ b/configurations/base.py @@ -5,6 +5,7 @@ from django.conf import global_settings from django.core.exceptions import ImproperlyConfigured from .utils import uppercase_attributes +from .values import Value, setup_value __all__ = ['Configuration'] @@ -70,6 +71,12 @@ class Configuration(six.with_metaclass(ConfigurationBase)): def post_setup(cls): pass + @classmethod + def setup(cls): + for name, value in uppercase_attributes(cls).items(): + if isinstance(value, Value): + setup_value(cls, name, value) + class Settings(Configuration): diff --git a/configurations/importer.py b/configurations/importer.py index 26cdbe6..e0710bf 100644 --- a/configurations/importer.py +++ b/configurations/importer.py @@ -10,8 +10,8 @@ from django.conf import ENVIRONMENT_VARIABLE as SETTINGS_ENVIRONMENT_VARIABLE from django.utils.decorators import available_attrs from django.utils.importlib import import_module -from .utils import uppercase_attributes - +from .utils import uppercase_attributes, reraise +from .values import Value, setup_value installed = False @@ -133,22 +133,6 @@ class ConfigurationImporter(object): return None -def reraise(exc, prefix=None, suffix=None): - args = exc.args - if not args: - args = ('',) - if prefix is None: - prefix = '' - elif not prefix.endswith((':', ': ')): - prefix = prefix + ': ' - if suffix is None: - suffix = '' - elif not (suffix.startswith('(') and suffix.endswith(')')): - suffix = '(' + suffix + ')' - exc.args = ('%s %s %s' % (prefix, exc.args[0], suffix),) + args[1:] - raise - - class ConfigurationLoader(object): def __init__(self, name, location): @@ -173,6 +157,11 @@ class ConfigurationLoader(object): except Exception as err: reraise(err, "While calling '{0}.pre_setup()'".format(cls_path)) + try: + cls.setup() + except Exception as err: + reraise(err, "While calling the '{0}.setup()'".format(cls_path)) + try: obj = cls() except Exception as err: @@ -192,6 +181,11 @@ class ConfigurationLoader(object): except Exception as err: reraise(err, "While calling '{0}.{1}'".format(cls_path, value)) + # in case a method returns a Value instance we have + # to do the same as the Configuration.setup method + if isinstance(value, Value): + setup_value(mod, name, value) + continue setattr(mod, name, value) setattr(mod, 'CONFIGURATION', '{0}.{1}'.format(fullname, self.name)) diff --git a/configurations/tests/test_values.py b/configurations/tests/test_values.py new file mode 100644 index 0000000..c6f4933 --- /dev/null +++ b/configurations/tests/test_values.py @@ -0,0 +1,292 @@ +import decimal +import os +from contextlib import contextmanager + +from django.test import TestCase +from django.core.exceptions import ImproperlyConfigured + +from mock import patch + +from configurations.values import (Value, BooleanValue, IntegerValue, + FloatValue, DecimalValue, ListValue, + TupleValue, SetValue, DictValue, + URLValue, EmailValue, IPValue, + RegexValue, PathValue, SecretValue, + DatabaseURLValue, EmailURLValue, + CacheURLValue, BackendsValue, + CastingMixin) + + +@contextmanager +def env(**kwargs): + with patch.dict(os.environ, clear=True, **kwargs): + yield + + +class FailingCasterValue(CastingMixin, Value): + caster = 'non.existing.caster' + + +class ValueTests(TestCase): + + def test_value(self): + value = Value('default') + self.assertEqual(value.setup('TEST'), 'default') + with env(DJANGO_TEST='override'): + self.assertEqual(value.setup('TEST'), 'default') + + @patch.dict(os.environ, clear=True, DJANGO_TEST='override') + def test_env_var(self): + value = Value('default', environ=True) + self.assertEqual(value.setup('TEST'), 'override') + self.assertNotEqual(value.setup('TEST'), value.default) + self.assertEqual(value.to_python(os.environ['DJANGO_TEST']), + value.setup('TEST')) + + def test_value_reuse(self): + value1 = Value('default', environ=True) + value2 = Value(value1, environ=True) + self.assertEqual(value1.setup('TEST1'), 'default') + self.assertEqual(value2.setup('TEST2'), 'default') + with env(DJANGO_TEST1='override1', DJANGO_TEST2='override2'): + self.assertEqual(value1.setup('TEST1'), 'override1') + self.assertEqual(value2.setup('TEST2'), 'override2') + + def test_env_var_prefix(self): + with patch.dict(os.environ, clear=True, ACME_TEST='override'): + value = Value('default', environ=True, environ_prefix='ACME') + self.assertEqual(value.setup('TEST'), 'override') + + with patch.dict(os.environ, clear=True, TEST='override'): + value = Value('default', environ=True, environ_prefix='') + self.assertEqual(value.setup('TEST'), 'override') + + def test_boolean_values_true(self): + value = BooleanValue(False, environ=True) + for truthy in value.true_values: + with env(DJANGO_TEST=truthy): + self.assertTrue(value.setup('TEST')) + + def test_boolean_values_faulty(self): + self.assertRaises(ValueError, BooleanValue, 'false') + + def test_boolean_values_false(self): + value = BooleanValue(True, environ=True) + for falsy in value.false_values: + with env(DJANGO_TEST=falsy): + self.assertFalse(value.setup('TEST')) + + def test_boolean_values_nonboolean(self): + value = BooleanValue(True, environ=True) + with env(DJANGO_TEST='nonboolean'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_integer_values(self): + value = IntegerValue(1, environ=True) + with env(DJANGO_TEST='2'): + self.assertEqual(value.setup('TEST'), 2) + with env(DJANGO_TEST='noninteger'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_float_values(self): + value = FloatValue(1.0, environ=True) + with env(DJANGO_TEST='2.0'): + self.assertEqual(value.setup('TEST'), 2.0) + with env(DJANGO_TEST='noninteger'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_decimal_values(self): + value = DecimalValue(decimal.Decimal(1), environ=True) + with env(DJANGO_TEST='2'): + self.assertEqual(value.setup('TEST'), decimal.Decimal(2)) + with env(DJANGO_TEST='nondecimal'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_failing_caster(self): + self.assertRaises(ImproperlyConfigured, FailingCasterValue) + + def test_list_values_default(self): + value = ListValue(environ=True) + with env(DJANGO_TEST='2,2'): + self.assertEqual(value.setup('TEST'), ['2', '2']) + with env(DJANGO_TEST='2, 2 ,'): + self.assertEqual(value.setup('TEST'), ['2', '2']) + with env(DJANGO_TEST=''): + self.assertEqual(value.setup('TEST'), []) + + def test_list_values_separator(self): + value = ListValue(environ=True, separator=':') + with env(DJANGO_TEST='/usr/bin:/usr/sbin:/usr/local/bin'): + self.assertEqual(value.setup('TEST'), + ['/usr/bin', '/usr/sbin', '/usr/local/bin']) + + def test_List_values_converter(self): + value = ListValue(environ=True, converter=int) + with env(DJANGO_TEST='2,2'): + self.assertEqual(value.setup('TEST'), [2, 2]) + + value = ListValue(environ=True, converter=float) + with env(DJANGO_TEST='2,2'): + self.assertEqual(value.setup('TEST'), [2.0, 2.0]) + + def test_list_values_custom_converter(self): + value = ListValue(environ=True, converter=lambda x: x * 2) + with env(DJANGO_TEST='2,2'): + self.assertEqual(value.setup('TEST'), ['22', '22']) + + def test_list_values_converter_exception(self): + value = ListValue(environ=True, converter=int) + with env(DJANGO_TEST='2,b'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_tuple_values_default(self): + value = TupleValue(environ=True) + with env(DJANGO_TEST='2,2'): + self.assertEqual(value.setup('TEST'), ('2', '2')) + with env(DJANGO_TEST='2, 2 ,'): + self.assertEqual(value.setup('TEST'), ('2', '2')) + with env(DJANGO_TEST=''): + self.assertEqual(value.setup('TEST'), ()) + + def test_set_values_default(self): + value = SetValue(environ=True) + with env(DJANGO_TEST='2,2'): + self.assertEqual(value.setup('TEST'), set(['2', '2'])) + with env(DJANGO_TEST='2, 2 ,'): + self.assertEqual(value.setup('TEST'), set(['2', '2'])) + with env(DJANGO_TEST=''): + self.assertEqual(value.setup('TEST'), set()) + + def test_dict_values_default(self): + value = DictValue(environ=True) + with env(DJANGO_TEST='{2: 2}'): + self.assertEqual(value.setup('TEST'), {2: 2}) + expected = {2: 2, '3': '3', '4': [1, 2, 3]} + with env(DJANGO_TEST="{2: 2, '3': '3', '4': [1, 2, 3]}"): + self.assertEqual(value.setup('TEST'), expected) + with env(DJANGO_TEST="""{ + 2: 2, + '3': '3', + '4': [1, 2, 3], + }"""): + self.assertEqual(value.setup('TEST'), expected) + with env(DJANGO_TEST=''): + self.assertEqual(value.setup('TEST'), {}) + with env(DJANGO_TEST='spam'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_email_values(self): + value = EmailValue('spam@eg.gs', environ=True) + with env(DJANGO_TEST='spam@sp.am'): + self.assertEqual(value.setup('TEST'), 'spam@sp.am') + with env(DJANGO_TEST='spam'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_url_values(self): + value = URLValue('http://eggs.spam', environ=True) + with env(DJANGO_TEST='http://spam.eggs'): + self.assertEqual(value.setup('TEST'), 'http://spam.eggs') + with env(DJANGO_TEST='httb://spam.eggs'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_ip_values(self): + value = IPValue('0.0.0.0', environ=True) + with env(DJANGO_TEST='127.0.0.1'): + self.assertEqual(value.setup('TEST'), '127.0.0.1') + with env(DJANGO_TEST='::1'): + self.assertEqual(value.setup('TEST'), '::1') + with env(DJANGO_TEST='spam.eggs'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_regex_values(self): + value = RegexValue('000--000', environ=True, regex=r'\d+--\d+') + with env(DJANGO_TEST='123--456'): + self.assertEqual(value.setup('TEST'), '123--456') + with env(DJANGO_TEST='123456'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_path_values_with_check(self): + value = PathValue(environ=True) + with env(DJANGO_TEST='/'): + self.assertEqual(value.setup('TEST'), '/') + with env(DJANGO_TEST='~/'): + self.assertEqual(value.setup('TEST'), os.path.expanduser('~')) + with env(DJANGO_TEST='/does/not/exist'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_path_values_no_check(self): + value = PathValue(environ=True, check_exists=False) + with env(DJANGO_TEST='/'): + self.assertEqual(value.setup('TEST'), '/') + with env(DJANGO_TEST='~/spam/eggs'): + self.assertEqual(value.setup('TEST'), + os.path.join(os.path.expanduser('~'), + 'spam', 'eggs')) + with env(DJANGO_TEST='/does/not/exist'): + self.assertEqual(value.setup('TEST'), '/does/not/exist') + + def test_secret_value(self): + self.assertRaises(ValueError, SecretValue, 'default') + value = SecretValue() + self.assertRaises(ValueError, value.setup, 'TEST') + with env(DJANGO_SECRET_KEY='123'): + self.assertEqual(value.setup('SECRET_KEY'), '123') + + def test_database_url_value(self): + value = DatabaseURLValue(environ=True) + self.assertEqual(value.default, {}) + with env(DATABASE_URL='sqlite://'): + self.assertEqual(value.setup('DATABASE_URL'), { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'HOST': None, + 'NAME': ':memory:', + 'PASSWORD': None, + 'PORT': None, + 'USER': None, + }}) + + def test_email_url_value(self): + value = EmailURLValue(environ=True) + self.assertEqual(value.default, {}) + with env(EMAIL_URL='smtps://user@domain.com:password@smtp.example.com:587'): + self.assertEqual(value.setup('EMAIL_URL'), { + 'EMAIL_BACKEND': 'django.core.mail.backends.smtp.EmailBackend', + 'EMAIL_FILE_PATH': '', + 'EMAIL_HOST': 'smtp.example.com', + 'EMAIL_HOST_PASSWORD': 'password', + 'EMAIL_HOST_USER': 'user@domain.com', + 'EMAIL_PORT': 587, + 'EMAIL_USE_TLS': True}) + with env(EMAIL_URL='console://'): + self.assertEqual(value.setup('EMAIL_URL'), { + 'EMAIL_BACKEND': 'django.core.mail.backends.console.EmailBackend', + 'EMAIL_FILE_PATH': '', + 'EMAIL_HOST': None, + 'EMAIL_HOST_PASSWORD': None, + 'EMAIL_HOST_USER': None, + 'EMAIL_PORT': None, + 'EMAIL_USE_TLS': False}) + with env(EMAIL_URL='smtps://user@domain.com:password@smtp.example.com:wrong'): + self.assertRaises(ValueError, value.setup, 'TEST') + + def test_cache_url_value(self): + value = CacheURLValue(environ=True) + self.assertEqual(value.default, {}) + with env(CACHE_URL='redis://user@host:port/1'): + self.assertEqual(value.setup('CACHE_URL'), { + 'default': { + 'BACKEND': 'redis_cache.cache.RedisCache', + 'KEY_PREFIX': '', + 'LOCATION': 'user@host:port:1' + }}) + with env(CACHE_URL='wrong://user@host:port/1'): + self.assertRaises(KeyError, value.setup, 'TEST') + + def test_backend_list_value(self): + backends = ['django.middleware.common.CommonMiddleware'] + value = BackendsValue(backends) + self.assertEqual(value.setup('TEST'), backends) + + backends = ['non.existing.Backend'] + self.assertRaises(ImproperlyConfigured, BackendsValue, backends) diff --git a/configurations/utils.py b/configurations/utils.py index d4ecd36..ddedcd8 100644 --- a/configurations/utils.py +++ b/configurations/utils.py @@ -1,3 +1,10 @@ +import sys + +from django.core.exceptions import ImproperlyConfigured +from django.utils import six +from django.utils.importlib import import_module + + def isuppercase(name): return name == name.upper() and not name.startswith('_') @@ -5,3 +12,50 @@ def isuppercase(name): def uppercase_attributes(obj): return dict((name, getattr(obj, name)) for name in filter(isuppercase, dir(obj))) + + +def import_by_path(dotted_path, error_prefix=''): + """ + Import a dotted module path and return the attribute/class designated by the + last name in the path. Raise ImproperlyConfigured if something goes wrong. + + Backported from Django 1.6. + """ + try: + module_path, class_name = dotted_path.rsplit('.', 1) + except ValueError: + raise ImproperlyConfigured("{0}{1} doesn't look like " + "a module path".format(error_prefix, + dotted_path)) + try: + module = import_module(module_path) + except ImportError as err: + msg = '{0}Error importing module {1}: "{2}"'.format(error_prefix, + module_path, + err) + six.reraise(ImproperlyConfigured, ImproperlyConfigured(msg), + sys.exc_info()[2]) + try: + attr = getattr(module, class_name) + except AttributeError: + raise ImproperlyConfigured('{0}Module "{1}" does not define a ' + '"{2}" attribute/class'.format(error_prefix, + module_path, + class_name)) + return attr + + +def reraise(exc, prefix=None, suffix=None): + args = exc.args + if not args: + args = ('',) + if prefix is None: + prefix = '' + elif not prefix.endswith((':', ': ')): + prefix = prefix + ': ' + if suffix is None: + suffix = '' + elif not (suffix.startswith('(') and suffix.endswith(')')): + suffix = '(' + suffix + ')' + exc.args = ('{0} {1} {2}'.format(prefix, exc.args[0], suffix),) + args[1:] + raise diff --git a/configurations/values.py b/configurations/values.py new file mode 100644 index 0000000..984f973 --- /dev/null +++ b/configurations/values.py @@ -0,0 +1,346 @@ +import ast +import copy +import decimal +import os + +from django.core import validators +from django.core.exceptions import ValidationError, ImproperlyConfigured +from django.utils import six + +from .utils import import_by_path + + +def setup_value(target, name, value): + actual_value = value.setup(name) + if value.multiple: + # overwriting the original Value class with the result + setattr(target, name, actual_value) + for multiple_name, multiple_value in actual_value.items(): + setattr(target, multiple_name, multiple_value) + else: + setattr(target, name, actual_value) + + +class Value(object): + """ + A single settings value that is able to interpret env variables + and implements a simple validation scheme. + """ + multiple = False + + def __init__(self, default=None, environ=False, environ_name=None, + environ_prefix='DJANGO', *args, **kwargs): + if isinstance(default, Value): + self.default = copy.copy(default.default) + else: + self.default = default + self.environ = environ + if environ_prefix and environ_prefix.endswith('_'): + environ_prefix = environ_prefix[:-1] + self.environ_prefix = environ_prefix + self.environ_name = environ_name + + def __repr__(self): + return "".format(self.default) + + def setup(self, name): + value = self.default + if self.environ: + if self.environ_name is None: + environ_name = name.upper() + else: + environ_name = self.environ_name + if self.environ_prefix: + full_environ_name = '{0}_{1}'.format(self.environ_prefix, + environ_name) + else: + full_environ_name = environ_name + if full_environ_name in os.environ: + value = self.to_python(os.environ[full_environ_name]) + return value + + def to_python(self, value): + """ + Convert the given value of a environment variable into an + appropriate Python representation of the value. + This should be overriden when subclassing. + """ + return value + + +class MultipleMixin(object): + multiple = True + + +class BooleanValue(Value): + true_values = ('yes', 'y', 'true', '1') + false_values = ('no', 'n', 'false', '0', '') + + def __init__(self, *args, **kwargs): + super(BooleanValue, self).__init__(*args, **kwargs) + if self.default not in (True, False): + raise ValueError('Default value {!r} is ' + 'not a boolean value'.format(self.default)) + + def to_python(self, value): + normalized_value = value.strip().lower() + if normalized_value in self.true_values: + return True + elif normalized_value in self.false_values: + return False + else: + raise ValueError('Cannot interpret ' + 'boolean value {!r}'.format(value)) + + +class CastingMixin(object): + exception = (TypeError, ValueError) + message = 'Cannot interpret value {!r}' + + def __init__(self, *args, **kwargs): + super(CastingMixin, self).__init__(*args, **kwargs) + if isinstance(self.caster, six.string_types): + self._caster = import_by_path(self.caster) + elif callable(self.caster): + self._caster = self.caster + else: + error = 'Cannot use caster of {0} ({1!r})'.format(self, + self.caster) + raise ImproperlyConfigured(error) + + def to_python(self, value): + try: + return self._caster(value) + except self.exception: + raise ValueError(self.message.format(value)) + + +class IntegerValue(CastingMixin, Value): + caster = int + + +class FloatValue(CastingMixin, Value): + caster = float + + +class DecimalValue(CastingMixin, Value): + caster = decimal.Decimal + exception = decimal.InvalidOperation + + +class ListValue(Value): + converter = None + message = 'Cannot interpret list item {!r} in list {!r}' + + def __init__(self, *args, **kwargs): + self.separator = kwargs.pop('separator', ',') + converter = kwargs.pop('converter', None) + if converter is not None: + self.converter = converter + super(ListValue, self).__init__(*args, **kwargs) + # make sure the default is a list + if self.default is None: + self.default = [] + # initial conversion + if self.converter is not None: + self.default = [self.converter(value) for value in self.default] + + def to_python(self, value): + split_value = [v.strip() for v in value.strip().split(self.separator)] + # removing empty items + value_list = filter(None, split_value) + if self.converter is None: + return value_list + + converted_values = [] + for list_value in value_list: + try: + converted_values.append(self.converter(list_value)) + except (TypeError, ValueError): + raise ValueError(self.message.format(list_value, value)) + return converted_values + + +class BackendsValue(ListValue): + + def converter(self, value): + import_by_path(value) + return value + + +class TupleValue(ListValue): + message = 'Cannot interpret tuple item {!r} in tuple {!r}' + + def __init__(self, *args, **kwargs): + super(TupleValue, self).__init__(*args, **kwargs) + if self.default is None: + self.default = () + else: + self.default = tuple(self.default) + + def to_python(self, value): + return tuple(super(TupleValue, self).to_python(value)) + + +class SetValue(ListValue): + message = 'Cannot interpret set item {!r} in set {!r}' + + def __init__(self, *args, **kwargs): + super(SetValue, self).__init__(*args, **kwargs) + if self.default is None: + self.default = set() + else: + self.default = set(self.default) + + def to_python(self, value): + return set(super(SetValue, self).to_python(value)) + + +class DictValue(Value): + + def __init__(self, *args, **kwargs): + super(DictValue, self).__init__(*args, **kwargs) + if self.default is None: + self.default = {} + else: + self.default = dict(self.default) + + def to_python(self, value): + value = super(DictValue, self).to_python(value) + if not value: + return {} + evaled_value = ast.literal_eval(value) + if not isinstance(evaled_value, dict): + raise ValueError('Cannot interpret dict value {!s}'.format(value)) + return evaled_value + + +class ValidationMixin(object): + + def __init__(self, *args, **kwargs): + super(ValidationMixin, self).__init__(*args, **kwargs) + if isinstance(self.validator, six.string_types): + self._validator = import_by_path(self.validator) + elif callable(self.validator): + self._validator = self.validator + else: + error = 'Cannot use validator of {0} ({1!r})'.format(self, + self.validator) + raise ImproperlyConfigured(error) + self.to_python(self.default) + + def to_python(self, value): + try: + self._validator(value) + except ValidationError: + raise ValueError(self.message.format(value)) + else: + return value + + +class EmailValue(ValidationMixin, Value): + message = 'Cannot interpret email value {!r}' + validator = 'django.core.validators.validate_email' + + +class URLValue(ValidationMixin, Value): + message = 'Cannot interpret URL value {!r}' + validator = validators.URLValidator() + + +class IPValue(ValidationMixin, Value): + message = 'Cannot interpret IP value {!r}' + validator = 'django.core.validators.validate_ipv46_address' + + +class RegexValue(ValidationMixin, Value): + message = "Regex doesn't match value {!r}" + + def __init__(self, *args, **kwargs): + regex = kwargs.pop('regex', None) + self.validator = validators.RegexValidator(regex=regex) + super(RegexValue, self).__init__(*args, **kwargs) + + +class PathValue(Value): + def __init__(self, *args, **kwargs): + self.check_exists = kwargs.pop('check_exists', True) + super(PathValue, self).__init__(*args, **kwargs) + + def setup(self, name): + value = super(PathValue, self).setup(name) + value = os.path.expanduser(value) + if self.check_exists and not os.path.exists(value): + raise ValueError('Path {!r} does not exist.'.format(value)) + return os.path.abspath(value) + + +class SecretValue(Value): + + def __init__(self, *args, **kwargs): + kwargs['environ'] = True + super(SecretValue, self).__init__(*args, **kwargs) + if self.default is not None: + raise ValueError('Secret values are only allowed to be ' + 'set as environment variables') + + def setup(self, name): + value = super(SecretValue, self).setup(name) + if not value: + raise ValueError('Secret value {!r} is not set'.format(name)) + return value + + +class DatabaseURLValue(CastingMixin, Value): + caster = 'dj_database_url.parse' + message = 'Cannot interpret database URL value {!r}' + + def __init__(self, *args, **kwargs): + self.alias = kwargs.pop('alias', 'default') + kwargs.setdefault('environ', True) + kwargs.setdefault('environ_prefix', None) + kwargs.setdefault('environ_name', 'DATABASE_URL') + super(DatabaseURLValue, self).__init__(*args, **kwargs) + if self.default is None: + self.default = {} + else: + self.default = self.to_python(self.default) + + def to_python(self, value): + value = super(DatabaseURLValue, self).to_python(value) + return {self.alias: value} + + +class EmailURLValue(CastingMixin, MultipleMixin, Value): + caster = 'dj_email_url.parse' + message = 'Cannot interpret email URL value {!r}' + + def __init__(self, *args, **kwargs): + kwargs.setdefault('environ', True) + kwargs.setdefault('environ_prefix', None) + kwargs.setdefault('environ_name', 'EMAIL_URL') + super(EmailURLValue, self).__init__(*args, **kwargs) + if self.default is None: + self.default = {} + else: + self.default = self.to_python(self.default) + + +class CacheURLValue(CastingMixin, Value): + caster = 'django_cache_url.parse' + message = 'Cannot interpret cache URL value {!r}' + + def __init__(self, name='default', *args, **kwargs): + self.alias = kwargs.pop('alias', 'default') + kwargs.setdefault('environ', True) + kwargs.setdefault('environ_prefix', None) + kwargs.setdefault('environ_name', 'CACHE_URL') + super(CacheURLValue, self).__init__(*args, **kwargs) + if self.default is None: + self.default = {} + else: + self.default = self.to_python(self.default) + + def to_python(self, value): + value = super(CacheURLValue, self).to_python(value) + return {self.alias: value} diff --git a/docs/changes.rst b/docs/changes.rst new file mode 100644 index 0000000..d9e113e --- /dev/null +++ b/docs/changes.rst @@ -0,0 +1 @@ +.. include:: ../CHANGES.rst diff --git a/docs/conf.py b/docs/conf.py index 7df928b..2472a96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,14 +48,9 @@ copyright = u'2012-2013, Jannis Leidel and other contributors' # |version| and |release|, also used in various other places throughout the # built documents. # -try: - from configurations import __version__ - # The short X.Y version. - version = '.'.join(__version__.split('.')[:2]) - # The full version, including alpha/beta/rc tags. - release = __version__ -except ImportError: - version = release = 'dev' +version = '0.4' +# The full version, including alpha/beta/rc tags. +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -291,4 +286,11 @@ epub_copyright = u'2012, Jannis Leidel' # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = { + 'python': ('http://docs.python.org/2.7', None), + 'sphinx': ('http://sphinx.pocoo.org/', None), + 'django': ('http://docs.djangoproject.com/en/dev/', + 'http://docs.djangoproject.com/en/dev/_objects/'), +} + +add_function_parentheses = add_module_names = False diff --git a/docs/cookbook.rst b/docs/cookbook.rst new file mode 100644 index 0000000..8b0da19 --- /dev/null +++ b/docs/cookbook.rst @@ -0,0 +1,77 @@ +Cookbook +======== + +Celery +------ + +Given Celery's way to load Django settings in worker processes you should +probably just add the following to the **begin** of your settings module:: + + from configurations import importer + importer.install() + +That has the same effect as using the ``manage.py`` or ``wsgi.py`` utilities +mentioned above. + +FastCGI +------- + +In case you use FastCGI for deploying Django (you really shouldn't) and aren't +allowed to us Django's runfcgi_ management command (that would automatically +handle the setup for your if you've followed the quickstart guide above), make +sure to use something like the following script:: + + #!/usr/bin/env python + + import os + import sys + + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings') + os.environ.setdefault('DJANGO_CONFIGURATION', 'MySiteConfiguration') + + from configurations.fastcgi import runfastcgi + + runfastcgi(method='threaded', daemonize='true') + +As you can see django-configurations provides a helper module +``configurations.fastcgi`` that handles the setup of your configurations. + +.. _runfcgi: https://docs.djangoproject.com/en/1.5/howto/deployment/fastcgi/ + +Envdir +------ + +envdir_ is an effective way to set a large number of environment variables +at once during startup of a command. This is great in combination with +django-configuration's :class:`~configurations.values.Value` subclasses +when enabling their ability to check environment variables for override +values. + +Imagine for example you want to set a few environment variables, all you +have to do is to create a directory with files that have capitalized names +and contain the values you want to set. + +Example:: + + $ tree mysite_env/ + mysite_env/ + ├── DJANGO_SETTINGS_MODULE + ├── DJANGO_DEBUG + ├── DJANGO_DATABASE_URL + ├── DJANGO_CACHE_URL + └── PYTHONSTARTUP + + 0 directories, 3 files + $ cat mysite_env/DJANGO_CACHE_URL + redis://user@host:port/1 + $ + +Then, to enable the ``mysite_env`` environment variables, simply use the +``envdir`` command line tool as a prefix for your program, e.g.:: + + $ envdir mysite_env python manage.py runserver + +See envdir_ documentation for more information, e.g. using envdir_ from +Python instead of from the command line. + +.. _envdir: https://pypi.python.org/pypi/envdir diff --git a/docs/index.rst b/docs/index.rst index 57e8dab..35ae500 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,161 +53,16 @@ behind the scenes. .. _`PEP 302`: http://www.python.org/dev/peps/pep-0302/ -Usage patterns --------------- +Further documentation +--------------------- -There are various configuration patterns that can be implemented with -django-configurations. The most common pattern is to have a base class -and various subclasses based on the enviroment they are supposed to be -used in, e.g. in production, staging and development. +.. toctree:: + :maxdepth: 3 -Server specific settings -^^^^^^^^^^^^^^^^^^^^^^^^ - -For example, imagine you have a base setting class in your **settings.py** -file:: - - from configurations import Settings - - class Base(Settings): - TIME_ZONE = 'Europe/Berlin' - - class Dev(Base): - DEBUG = True - TEMPLATE_DEBUG = DEBUG - - class Prod(Base): - TIME_ZONE = 'America/New_York' - -You can now set the ``DJANGO_CONFIGURATION`` environment variable to one -of the class names you've defined, e.g. on your production server it -should be ``Prod``. In bash that would be:: - - export DJANGO_SETTINGS_MODULE=mysite.settings - export DJANGO_CONFIGURATION=Prod - python manage.py runserver - -Alternatively you can use the ``--configuration`` option when using Django -management commands along the lines of Django's default ``--settings`` -command line option, e.g.:: - - python manage.py runserver --settings=mysite.settings --configuration=Prod - -Global settings defaults -^^^^^^^^^^^^^^^^^^^^^^^^ - -Every ``configurations.Settings`` subclass will automatically contain -Django's global settings as class attributes, so you can refer to them when -setting other values, e.g.:: - - from configurations import Settings - - class Prod(Settings): - TEMPLATE_CONTEXT_PROCESSORS = Settings.TEMPLATE_CONTEXT_PROCESSORS + ( - 'django.core.context_processors.request', - ) - - @property - def LANGUAGES(self): - return Settings.LANGUAGES + (('tlh', 'Klingon'),) - -Mixins -^^^^^^ - -You might want to apply some configuration values for each and every -project you're working on without having to repeat yourself. Just define -a few mixin you re-use multiple times:: - - class FullPageCaching(object): - USE_ETAGS = True - -Then import that mixin class in your site settings module and use it with -a Settings class:: - - from configurations import Settings - - class Prod(Settings, FullPageCaching): - DEBUG = False - # ... - -Pristine methods -^^^^^^^^^^^^^^^^ - -.. versionadded:: 0.3 - -In case one of your settings itself need to be a callable, you need to -tell that django-configurations by using the ``pristinemethod`` decorator, -e.g.:: - - from configurations import Settings, pristinemethod - - class Prod(Settings): - - @pristinemethod - def ACCESS_FUNCTION(user): - return user.is_staff - -Lambdas work, too:: - - from configurations import Settings, pristinemethod - - class Prod(Settings): - ACCESS_FUNCTION = pristinemethod(lambda user: user.is_staff) - -Setup methods -^^^^^^^^^^^^^ - -.. versionadded:: 0.3 - -If there is something required to be set up before or after the settings -loading happens, please override the ``pre_setup`` or ``post_setup`` -class methods like so (don't forget to apply the Python ``@classmethod`` -decorator:: - - from configurations import Settings - - class Prod(Settings): - # ... - - @classmethod - def pre_setup(cls): - if something.completely.different(): - cls.DEBUG = True - - @classmethod - def post_setup(cls): - print("done setting up! \o/") - -As you can see above the ``pre_setup`` method can also be used to -programmatically change a class attribute of the settings class and it -will be taken into account when doing the rest of the settings setup. -Of course that won't work for ``post_setup`` since that's when the -settings setup is already done. - -In fact you can easily do something unrelated to settings, like -connecting to a database:: - - from configurations import Settings - - class Prod(Settings): - # ... - - @classmethod - def post_setup(cls): - import mango - mango.connect('enterprise') - - -.. warning:: - - You could do the same by overriding the ``__init__`` method of your - settings class but this may cause hard to debug errors because - at the time the ``__init__`` method is called (during Django startup) - the Django setting system isn't fully loaded yet. - - So anything you do in ``__init__`` that may require - ``django.conf.settings`` or Django models there is a good chance it - won't work. Use the ``post_setup`` method for that instead. + patterns + values + cookbook + changes Alternatives ------------ @@ -224,54 +79,15 @@ Many thanks to those project that have previously solved these problems: .. _Pinax: http://pinaxproject.com .. _`django-classbasedsettings`: https://github.com/matthewwithanm/django-classbasedsettings -Cookbook --------- - -Celery -^^^^^^ - -Given Celery's way to load Django settings in worker processes you should -probably just add the following to the **begin** of your settings module:: - - from configurations import importer - importer.install() - -That has the same effect as using the ``manage.py`` or ``wsgi.py`` utilities -mentioned above. - -FastCGI -^^^^^^^ - -In case you use FastCGI for deploying Django (you really shouldn't) and aren't -allowed to us Django's runfcgi_ management command (that would automatically -handle the setup for your if you've followed the quickstart guide above), make -sure to use something like the following script:: - - #!/usr/bin/env python - - import os - import sys - - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings') - os.environ.setdefault('DJANGO_CONFIGURATION', 'MySiteSettings') - - from configurations.fastcgi import runfastcgi - - runfastcgi(method='threaded', daemonize='true') - -As you can see django-configurations provides a helper module -``configurations.fastcgi`` that handles the setup of your configurations. - -.. _runfcgi: https://docs.djangoproject.com/en/1.5/howto/deployment/fastcgi/ Bugs and feature requests ------------------------- -As always you mileage may vary, so please don't hesitate to send in feature -requests and bug reports at the usual place: +As always your mileage may vary, so please don't hesitate to send feature +requests and bug reports: https://github.com/jezdez/django-configurations/issues -Thanks! +Thanks! Feel free to leave a tip, too: -.. include:: ../CHANGES.rst + https://www.gittip.com/jezdez/ diff --git a/docs/patterns.rst b/docs/patterns.rst new file mode 100644 index 0000000..5c8100a --- /dev/null +++ b/docs/patterns.rst @@ -0,0 +1,167 @@ +Usage patterns +============== + +There are various configuration patterns that can be implemented with +django-configurations. The most common pattern is to have a base class +and various subclasses based on the enviroment they are supposed to be +used in, e.g. in production, staging and development. + +Server specific settings +------------------------ + +For example, imagine you have a base setting class in your **settings.py** +file:: + + from configurations import Configuration + + class Base(Configuration): + TIME_ZONE = 'Europe/Berlin' + + class Dev(Base): + DEBUG = True + TEMPLATE_DEBUG = DEBUG + + class Prod(Base): + TIME_ZONE = 'America/New_York' + +You can now set the ``DJANGO_CONFIGURATION`` environment variable to one +of the class names you've defined, e.g. on your production server it +should be ``Prod``. In bash that would be:: + + export DJANGO_SETTINGS_MODULE=mysite.settings + export DJANGO_CONFIGURATION=Prod + python manage.py runserver + +Alternatively you can use the ``--configuration`` option when using Django +management commands along the lines of Django's default ``--settings`` +command line option, e.g.:: + + python manage.py runserver --settings=mysite.settings --configuration=Prod + +Global settings defaults +------------------------ + +Every ``configurations.Configuration`` subclass will automatically contain +Django's global settings as class attributes, so you can refer to them when +setting other values, e.g.:: + + from configurations import Configuration + + class Prod(Configuration): + TEMPLATE_CONTEXT_PROCESSORS = Configuration.TEMPLATE_CONTEXT_PROCESSORS + ( + 'django.core.context_processors.request', + ) + + @property + def LANGUAGES(self): + return Configuration.LANGUAGES + (('tlh', 'Klingon'),) + +Configuration mixins +-------------------- + +You might want to apply some configuration values for each and every +project you're working on without having to repeat yourself. Just define +a few mixin you re-use multiple times:: + + class FullPageCaching(object): + USE_ETAGS = True + +Then import that mixin class in your site settings module and use it with +a ``Configuration`` class:: + + from configurations import Configuration + + class Prod(Configuration, FullPageCaching): + DEBUG = False + # ... + +Pristine methods +---------------- + +.. versionadded:: 0.3 + +In case one of your settings itself need to be a callable, you need to +tell that django-configurations by using the ``pristinemethod`` decorator, +e.g.:: + + from configurations import Configuration, pristinemethod + + class Prod(Configuration): + + @pristinemethod + def ACCESS_FUNCTION(user): + return user.is_staff + +Lambdas work, too:: + + from configurations import Configuration, pristinemethod + + class Prod(Configuration): + ACCESS_FUNCTION = pristinemethod(lambda user: user.is_staff) + +Setup methods +------------- + +.. versionadded:: 0.3 + +If there is something required to be set up before, during or after the +settings loading happens, please override the ``pre_setup``, ``setup`` or +``post_setup`` class methods like so (don't forget to apply the Python +``@classmethod`` decorator):: + + import logging + from configurations import Configuration + + class Prod(Configuration): + # ... + + @classmethod + def pre_setup(cls): + if something.completely.different(): + cls.DEBUG = True + + @classmethod + def setup(cls): + super(Prod, cls).setup() + logging.info('production settings loaded: %s', cls) + + @classmethod + def post_setup(cls): + logging.debug("done setting up! \o/") + +As you can see above the ``pre_setup`` method can also be used to +programmatically change a class attribute of the settings class and it +will be taken into account when doing the rest of the settings setup. +Of course that won't work for ``post_setup`` since that's when the +settings setup is already done. + +In fact you can easily do something unrelated to settings, like +connecting to a database:: + + from configurations import Configuration + + class Prod(Configuration): + # ... + + @classmethod + def post_setup(cls): + import mango + mango.connect('enterprise') + + +.. warning:: + + You could do the same by overriding the ``__init__`` method of your + settings class but this may cause hard to debug errors because + at the time the ``__init__`` method is called (during Django startup) + the Django setting system isn't fully loaded yet. + + So anything you do in ``__init__`` that may require + ``django.conf.settings`` or Django models there is a good chance it + won't work. Use the ``post_setup`` method for that instead. + +.. versionchanged:: 0.4 + + A new ``setup`` method was added to be able to handle the new + :class:`~configurations.values.Value` classes and allow a in-between + modification of the configuration values. diff --git a/docs/values.rst b/docs/values.rst new file mode 100644 index 0000000..69063b6 --- /dev/null +++ b/docs/values.rst @@ -0,0 +1,458 @@ +Values +====== + +.. module:: configurations.values + :synopsis: Optional value classes for high-level validation and behavior. + +.. versionadded:: 0.4 + + django-configurations allows you to optionally reduce the amount of validation + and setup code in your **settings.py** by using ``Value`` classes. They have + the ability to handle values from the process environment of your software + (:data:`os.environ`) and work well in projects that follow the + `Twelve-Factor methodology`_. + +Overview +-------- + +Here is an example (from a **settings.py**):: + + from configurations import values + + DEBUG = values.BooleanValue(True) + +As you can see all you have to do is to wrap your settings value in a call +to one of the included settings classes. When Django's process starts up +it will automatically make sure the passed in value validates correctly -- +in the above case checks if the value is really a boolean. + +You can safely use other :class:`~Value` instances as the default setting +value:: + + from configurations import values + + DEBUG = values.BooleanValue(True) + TEMPLATE_DEBUG = values.BooleanValue(DEBUG) + +See the list of built-in value classes for more information. + +Environment variables +--------------------- + +To separate configuration from your application you should use environment +variables to override settings values if needed. Unfortunately environment +variables are string based so they are not easily mapped to the Python based +settings system Django uses. + +Luckily django-configurations' :class:`~Value` subclasses have the ability +to handle environment variables for the most common use cases. + +For example, imagine you'd like to override the ``TEMPLATE_DEBUG`` setting +on your staging server to be able to debug a problem with your in-development +code. You're using a web server that passes the environment variables from +the shell it was started in to your Django WSGI process. + +First make sure you set the ``environ`` option of the :class:`~Value` instance +to ``True``:: + + from configurations import values + + # .. + TEMPLATE_DEBUG = values.BooleanValue(True, environ=True) + +That will tell django-configurations to look for a environment variable +named ``DJANGO_TEMPLATE_DEBUG`` when deciding which value of the ``DEBUG`` +setting to actually enable. + +When you run your web server simply specify that environment variable +(e.g. in your init script):: + + DJANGO_TEMPLATE_DEBUG=true gunicorn mysite.wsgi:application + +Since environment variables are string based the ``BooleanValue`` supports +a series of possible formats for a boolean value (``true``, ``yes``, +``y`` and ``1``, all in capital and lower case, and ``false``, ``no``, +``n`` and ``0`` of course). So for example this will work, too:: + + DJANGO_TEMPLATE_DEBUG=no ./manage.py runserver + +``Value`` class +--------------- + +.. class:: Value(default, [environ=False, environ_name=None, environ_prefix='DJANGO']) + + The ``Value`` class takes one required and several optional parameters. + + :param default: the default value of the setting + :param environ: toggle for environment use + :param environ_name: name of environment variable to look for + :param environ_prefix: prefix to use when looking for environment variable + :type environ: bool + :type environ_name: capitalized string or None + :type environ_prefix: capitalized string + + The ``default`` parameter is effectively the value the setting has + right now in your ``settings.py``. + + .. method:: setup(name) + + :param name: the name of the setting + :return: setting value + + The ``setup`` method is called during startup of the Django process and + implements the ability to check the environment variable. Its purpose is + to return a value django-configrations is supposed to use when loading + the settings. It'll be passed one parameter, the name of the + :class:`~Value` instance as defined in the ``settings.py``. This is used + for building the name of the environment variable. + + .. method:: to_python(value) + + :param value: the value of the setting as found in the process + environment (:data:`os.environ`) + :return: validated and "ready" setting value if found in process + environment + + The ``to_python`` method is only used when the ``environ`` parameter + of the :class:`~Value` class is set to ``True`` and an environment + variable with the appropriate name was found. It will be used to handle + the string based environment variable values and returns the "ready" + value to be returned by the ``setup`` method. + +Built-ins +--------- + +Type values +^^^^^^^^^^^ + +.. class:: BooleanValue + + A :class:`~Value` subclass that checks and returns boolean values. Possible + values for environment variables are: + + - ``True`` values: ``'yes'``, ``'y'``, ``'true'``, ``'1'`` + - ``False`` values: ``'no'``, ``'n'``, ``'false'``, ``'0'``, + ``''`` (empty string) + + :: + + DEBUG = values.BooleanValue(True) + +.. class:: IntegerValue + + A :class:`~Value` subclass that handles integer values. + + :: + + MYSITE_CACHE_TIMEOUT = values.BooleanValue(3600) + +.. class:: FloatValue + + A :class:`~Value` subclass that handles float values. + + :: + + MYSITE_TAX_RATE = values.FloatValue(11.9) + +.. class:: DecimalValue + + A :class:`~Value` subclass that handles Decimal values. + + :: + + MYSITE_CONVERSION_RATE = values.DecimalValue(decimal.Decimal('4.56214')) + +.. class:: ListValue(default, [separator=',', converter=None]) + + A :class:`~Value` subclass that handles list values. + + :param separator: the separator to split environment variables with + :param converter: the optional converter callable to apply for each list + item + + Simple example:: + + ALLOWED_HOSTS = ListValue(['mysite.com', 'mysite.biz']) + + Use a custom converter to check for the given variables:: + + def check_monty_python(person): + if not is_completely_different(person): + raise ValueError('{0} is not a Monty Python member'.format(person)) + return person + + MONTY_PYTHONS = ListValue(['John Cleese', 'Eric Idle'], + converter=check_monty_python, + environ=True) + + You can override this list with an environment variable like this:: + + DJANGO_MONTY_PYTHONS="Terry Jones,Graham Chapman" gunicorn mysite.wsgi:application + + Use a custom separator:: + + EMERGENCY_EMAILS = ListValue(['admin@mysite.net'], + separator=';', environ=True) + + And override it:: + + DJANGO_EMERGENCY_EMAILS="admin@mysite.net;manager@mysite.org;support@mysite.com" gunicorn mysite.wsgi:application + +.. class:: TupleValue + + A :class:`~Value` subclass that handles tuple values. + + :param separator: the separator to split environment variables with + :param converter: the optional converter callable to apply for each tuple + item + + See the :class:`~ListValue` examples above. + +.. class:: SetValue + + A :class:`~Value` subclass that handles set values. + + :param separator: the separator to split environment variables with + :param converter: the optional converter callable to apply for each set + item + + See the :class:`~ListValue` examples above. + +.. class:: DictValue + +Validator values +^^^^^^^^^^^^^^^^ + +.. class:: EmailValue + + A :class:`~Value` subclass that validates the value using the + :data:`django:django.core.validators.validate_email` validator. + + :: + + SUPPORT_EMAIL = values.EmailValue('support@mysite.com') + +.. class:: URLValue + + A :class:`~Value` subclass that validates the value using the + :class:`django:django.core.validators.URLValidator` validator. + + :: + + SUPPORT_URL = values.URLValue('https://support.mysite.com/') + +.. class:: IPValue + + A :class:`~Value` subclass that validates the value using the + :data:`django:django.core.validators.validate_ipv46_address` validator. + + :: + + LOADBALANCER_IP = values.IPValue('127.0.0.1') + +.. class:: RegexValue(default, regex, [environ=False, environ_name=None, environ_prefix='DJANGO']) + + A :class:`~Value` subclass that validates according a regular expression + and uses the :class:`django:django.core.validators.RegexValidator`. + + :param regex: the regular expression + + :: + + DEFAULT_SKU = values.RegexValue('000-000-00', regex=r'\d{3}-\d{3}-\d{2}') + +.. class:: PathValue(default, [check_exists=True, environ=False, environ_name=None, environ_prefix='DJANGO']) + + A :class:`~Value` subclass that normalizes the given path using + :func:`os.path.expanduser` and validates if it exists on the file system. + + Takes an optional ``check_exists`` parameter to disable the check with + :func:`os.path.exists`. + + :param check_exists: toggle the file system check + + :: + + BASE_DIR = values.PathValue('/opt/mysite/') + +URL-based values +^^^^^^^^^^^^^^^^ + +.. note:: + + The following URL-based :class:`~Value` subclasses behave slightly different + to the rest of the classes here. They are inspired by the + `Twelve-Factor methodology`_ and by default inspect the :data:`os.environ` + using a previously established environment variable name, e.g. + ``DATABASE_URL``. + + Each of them require having an external library installed, e.g. the + :class:`~DatabaseURLValue` class depends on the package ``dj-database-url``. + +.. class:: DatabaseURLValue(default, [alias='default', environ=True, environ_name='DATABASE_URL', environ_prefix=None]) + + A :class:`~Value` subclass that uses the `dj-database-url`_ app to + convert a database configuration value stored in the ``DATABASE_URL`` + environment variable into an appropriate setting value. It's inspired by + the `Twelve-Factor methodology`_. + + By default this :class:`~Value` subclass looks for the ``DATABASE_URL`` + environment variable. + + Takes an optional ``alias`` parameter to define which database alias to + use for the ``DATABASES`` setting. + + :param alias: which database alias to use + + The other parameters have the following default values: + + :param environ: ``True`` + :param environ_name: ``DATABASE_URL`` + :param environ_prefix: ``None`` + + :: + + DATABASES = values.DatabaseURLValue('postgres://myuser@localhost/mydb') + + .. _`dj-database-url`: https://pypi.python.org/pypi/dj-database-url/ + +.. class:: CacheURLValue(default, [alias='default', environ=True, environ_name='CACHE_URL', environ_prefix=None]) + + A :class:`~Value` subclass that uses the `django-cache-url`_ app to + convert a cache configuration value stored in the ``CACHE_URL`` + environment variable into an appropriate setting value. It's inspired by + the `Twelve-Factor methodology`_. + + By default this :class:`~Value` subclass looks for the ``CACHE_URL`` + environment variable. + + Takes an optional ``alias`` parameter to define which database alias to + use for the ``CACHES`` setting. + + :param alias: which cache alias to use + + The other parameters have the following default values: + + :param environ: ``True`` + :param environ_name: ``CACHE_URL`` + :param environ_prefix: ``None`` + + :: + + CACHES = values.CacheURLValue('memcached://127.0.0.1:11211/') + + .. _`django-cache-url`: https://pypi.python.org/pypi/django-cache-url/ + +.. class:: EmailURLValue(default, [environ=True, environ_name='EMAIL_URL', environ_prefix=None]) + + A :class:`~Value` subclass that uses the `dj-email-url`_ app to + convert an email configuration value stored in the ``EMAIL_URL`` + environment variable into the appropriate settings. It's inspired by + the `Twelve-Factor methodology`_. + + By default this :class:`~Value` subclass looks for the ``EMAIL_URL`` + environment variable. + + .. note:: + + This is a special value since email settings are divided into many + different settings. `dj-email-url`_ supports all options though and + simply returns a nested dictionary of settings instead of just one + setting. + + The parameters have the following default values: + + :param environ: ``True`` + :param environ_name: ``EMAIL_URL`` + :param environ_prefix: ``None`` + + :: + + EMAIL_URL = values.EmailURLValue('console://') + + .. _`dj-email-url`: https://pypi.python.org/pypi/dj-email-url/ + +Other values +^^^^^^^^^^^^ + +.. class:: BackendsValue + + A :class:`~ListValue` subclass that validates the given list of dotted + import paths by trying to import them. In other words, this checks if + the backends exist. + + :: + + MIDDLEWARE_CLASSES = values.BackendsValue([ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + ]) + +.. class:: SecretValue + + A :class:`~Value` subclass that doesn't allow setting a default value + during instantiation and force-enables the use of an environment variable + to reduce the risk of accidentally storing secret values in the settings + file. + + :raises: ``ValueError`` when given a default value + + :: + + SECRET_KEY = values.SecretValue() + +Value mixins +^^^^^^^^^^^^ + +.. class:: CastingMixin + + A mixin to be used with one of the :class:`~Value` subclasses that + requires a ``caster`` class attribute of one of the following types: + + - dotted import path, e.g. ``'mysite.utils.custom_caster'`` + - a callable, e.g. :func:`int` + + Example:: + + class TemparatureValue(CastingMixin, Value): + caster = 'mysite.temperature.fahrenheit_to_celcius' + + Optionally it can take a ``message`` class attribute as the error + message to be shown if the casting fails. Additionally an ``exception`` + parameter can be set to a single or a tuple of exception classes that + are required to be handled during the casting. + +.. class:: ValidationMixin + + A mixin to be used with one of the :class:`~Value` subclasses that + requires a ``validator`` class attribute of one of the following types: + The validator should raise Django's + :exc:`~django.core.exceptions.ValidationError` to indicate a failed + validation attempt. + + - dotted import path, e.g. ``'mysite.validators.custom_validator'`` + - a callable, e.g. :func:`bool` + + Example:: + + class TemparatureValue(ValidationMixin, Value): + validator = 'mysite.temperature.is_valid_temparature' + + Optionally it can take a ``message`` class attribute as the error + message to be shown if the validation fails. + +.. class:: MultipleMixin + + A mixin to be used with one of the :class:`~Value` subclasses that + enables the return value of the :func:`~Value.to_python` to be + interpreted as a dictionary of settings values to be set at once, + instead of using the return value to just set one setting. + + A good example for this mixin is the :class:`~EmailURLValue` value + which requires setting many ``EMAIL_*`` settings. + +.. _`Twelve-Factor methodology`: http://www.12factor.net/ diff --git a/requirements/tests.txt b/requirements/tests.txt index 8c15f07..87d16d4 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,4 +1,7 @@ flake8 coverage django-discover-runner -mock \ No newline at end of file +mock +dj-database-url +dj-email-url +django-cache-url diff --git a/setup.cfg b/setup.cfg index db1f260..17501cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,7 +6,6 @@ author-email = jannis@leidel.info summary = A helper for organizing Django settings. description-file = README.rst license = BSD -requires-dist = six home-page = http://django-configurations.readthedocs.org/ project-url = Github, https://github.com/jezdez/django-configurations/ diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index e00d431..d3a1086 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -1,16 +1,18 @@ -from configurations import Settings +from configurations import Configuration, values -class Base(Settings): +class Base(Configuration): # Django settings for test_project project. - DEBUG = True + DEBUG = values.BooleanValue(True, environ=True) TEMPLATE_DEBUG = DEBUG ADMINS = ( # ('Your Name', 'your_email@example.com'), ) + EMAIL_URL = values.EmailURLValue('console://', environ=True) + MANAGERS = ADMINS DATABASES = {