diff --git a/configurations/__init__.py b/configurations/__init__.py index bd06566..e13151a 100644 --- a/configurations/__init__.py +++ b/configurations/__init__.py @@ -1,9 +1,9 @@ from .base import Configuration # noqa -from .decorators import pristinemethod # noqa +from .decorators import environ_prefix, pristinemethod # noqa from .version import __version__ # noqa -__all__ = ['Configuration', 'pristinemethod'] +__all__ = ['Configuration', 'environ_prefix', 'pristinemethod'] def _setup(): diff --git a/configurations/base.py b/configurations/base.py index 6065ed0..00d1ab1 100644 --- a/configurations/base.py +++ b/configurations/base.py @@ -4,7 +4,7 @@ from django.conf import global_settings from django.core.exceptions import ImproperlyConfigured -from .utils import uppercase_attributes +from .utils import uppercase_attributes, UNSET from .values import Value, setup_value __all__ = ['Configuration'] @@ -99,6 +99,7 @@ def OTHER(self): """ DOTENV_LOADED = None + _environ_prefix = UNSET @classmethod def load_dotenv(cls): @@ -154,4 +155,5 @@ def post_setup(cls): def setup(cls): for name, value in uppercase_attributes(cls).items(): if isinstance(value, Value): + value._class_environ_prefix = cls._environ_prefix setup_value(cls, name, value) diff --git a/configurations/decorators.py b/configurations/decorators.py index a0c85f0..e7629b1 100644 --- a/configurations/decorators.py +++ b/configurations/decorators.py @@ -1,3 +1,6 @@ +from django.core.exceptions import ImproperlyConfigured + + def pristinemethod(func): """ A decorator for handling pristine settings like callables. @@ -17,3 +20,30 @@ def USER_CHECK(user): """ func.pristine = True return staticmethod(func) + + +def environ_prefix(prefix): + """ + A class Configuration class decorator that prefixes ``prefix`` + to environment names. + + Use it like this:: + + @environ_prefix("MYAPP") + class Develop(Configuration): + SOMETHING = values.Value() + + To remove the prefix from environment names:: + + @environ_prefix(None) + class Develop(Configuration): + SOMETHING = values.Value() + + """ + if not isinstance(prefix, (type(None), str)): + raise ImproperlyConfigured("environ_prefix accepts only str and None values.") + + def decorator(conf_cls): + conf_cls._environ_prefix = prefix + return conf_cls + return decorator diff --git a/configurations/utils.py b/configurations/utils.py index aabd009..a377416 100644 --- a/configurations/utils.py +++ b/configurations/utils.py @@ -99,3 +99,11 @@ def getargspec(func): if not inspect.isfunction(func): raise TypeError('%r is not a Python function' % func) return inspect.getfullargspec(func) + + +class Unset: + def __repr__(self): # pragma: no cover + return "UNSET" + + +UNSET = Unset() diff --git a/configurations/values.py b/configurations/values.py index b1a9e9b..2fbfc56 100644 --- a/configurations/values.py +++ b/configurations/values.py @@ -6,9 +6,10 @@ from django.core import validators from django.core.exceptions import ValidationError, ImproperlyConfigured +from django.utils.functional import cached_property from django.utils.module_loading import import_string -from .utils import getargspec +from .utils import getargspec, UNSET def setup_value(target, name, value): @@ -58,16 +59,14 @@ def __new__(cls, *args, **kwargs): return instance def __init__(self, default=None, environ=True, environ_name=None, - environ_prefix='DJANGO', environ_required=False, + environ_prefix=UNSET, environ_required=False, *args, **kwargs): if isinstance(default, Value) and default.default is not None: 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_prefix = environ_prefix self.environ_name = environ_name self.environ_required = environ_required @@ -116,6 +115,19 @@ def to_python(self, value): """ return value + @cached_property + def environ_prefix(self): + prefix = UNSET + if self._environ_prefix is not UNSET: + prefix = self._environ_prefix + elif (class_prefix := getattr(self, "_class_environ_prefix", UNSET)) is not UNSET: + prefix = class_prefix + if prefix is not UNSET: + if isinstance(prefix, str) and prefix.endswith("_"): + return prefix[:-1] + return prefix + return "DJANGO" + class MultipleMixin: multiple = True diff --git a/tests/settings/prefix_decorator.py b/tests/settings/prefix_decorator.py new file mode 100644 index 0000000..ab82ac4 --- /dev/null +++ b/tests/settings/prefix_decorator.py @@ -0,0 +1,26 @@ +from configurations import Configuration, environ_prefix, values + + +@environ_prefix("ACME") +class PrefixDecoratorConf1(Configuration): + FOO = values.Value() + + +@environ_prefix("ACME") +class PrefixDecoratorConf2(Configuration): + FOO = values.BooleanValue(False) + + +@environ_prefix("ACME") +class PrefixDecoratorConf3(Configuration): + FOO = values.Value(environ_prefix="ZEUS") + + +@environ_prefix("") +class PrefixDecoratorConf4(Configuration): + FOO = values.Value() + + +@environ_prefix(None) +class PrefixDecoratorConf5(Configuration): + FOO = values.Value() diff --git a/tests/test_prefix_decorator.py b/tests/test_prefix_decorator.py new file mode 100644 index 0000000..93cfd47 --- /dev/null +++ b/tests/test_prefix_decorator.py @@ -0,0 +1,57 @@ +import os +import importlib + +from django.core.exceptions import ImproperlyConfigured +from django.test import TestCase +from unittest.mock import patch + +from configurations import environ_prefix +from tests.settings import prefix_decorator + + +class EnvironPrefixDecoratorTests(TestCase): + @patch.dict(os.environ, clear=True, + DJANGO_CONFIGURATION="PrefixDecoratorConf1", + DJANGO_SETTINGS_MODULE="tests.settings.prefix_decorator", + ACME_FOO="bar") + def test_prefix_decorator_with_value(self): + importlib.reload(prefix_decorator) + self.assertEqual(prefix_decorator.FOO, "bar") + + @patch.dict(os.environ, clear=True, + DJANGO_CONFIGURATION="PrefixDecoratorConf2", + DJANGO_SETTINGS_MODULE="tests.settings.prefix_decorator", + ACME_FOO="True") + def test_prefix_decorator_for_value_subclasses(self): + importlib.reload(prefix_decorator) + self.assertIs(prefix_decorator.FOO, True) + + @patch.dict(os.environ, clear=True, + DJANGO_CONFIGURATION="PrefixDecoratorConf3", + DJANGO_SETTINGS_MODULE="tests.settings.prefix_decorator", + ZEUS_FOO="bar") + def test_value_prefix_takes_precedence(self): + importlib.reload(prefix_decorator) + self.assertEqual(prefix_decorator.FOO, "bar") + + @patch.dict(os.environ, clear=True, + DJANGO_CONFIGURATION="PrefixDecoratorConf4", + DJANGO_SETTINGS_MODULE="tests.settings.prefix_decorator", + FOO="bar") + def test_prefix_decorator_empty_string_value(self): + importlib.reload(prefix_decorator) + self.assertEqual(prefix_decorator.FOO, "bar") + + @patch.dict(os.environ, clear=True, + DJANGO_CONFIGURATION="PrefixDecoratorConf5", + DJANGO_SETTINGS_MODULE="tests.settings.prefix_decorator", + FOO="bar") + def test_prefix_decorator_none_value(self): + importlib.reload(prefix_decorator) + self.assertEqual(prefix_decorator.FOO, "bar") + + def test_prefix_value_must_be_none_or_str(self): + class Conf: + pass + + self.assertRaises(ImproperlyConfigured, lambda: environ_prefix(1)(Conf))