Skip to content

Commit

Permalink
add environ_prefix class decorator (#240)
Browse files Browse the repository at this point in the history
- allows to configure environ_prefix on a per-class basis
  • Loading branch information
g-nie committed Dec 2, 2024
1 parent 3d0d421 commit 14776c8
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 8 deletions.
4 changes: 2 additions & 2 deletions configurations/__init__.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
4 changes: 3 additions & 1 deletion configurations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -99,6 +99,7 @@ def OTHER(self):
"""
DOTENV_LOADED = None
_environ_prefix = UNSET

@classmethod
def load_dotenv(cls):
Expand Down Expand Up @@ -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)
30 changes: 30 additions & 0 deletions configurations/decorators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from django.core.exceptions import ImproperlyConfigured


def pristinemethod(func):
"""
A decorator for handling pristine settings like callables.
Expand All @@ -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
8 changes: 8 additions & 0 deletions configurations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
22 changes: 17 additions & 5 deletions configurations/values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/settings/prefix_decorator.py
Original file line number Diff line number Diff line change
@@ -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()
57 changes: 57 additions & 0 deletions tests/test_prefix_decorator.py
Original file line number Diff line number Diff line change
@@ -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))

0 comments on commit 14776c8

Please sign in to comment.