mirror of
https://github.com/jazzband/django-configurations.git
synced 2026-05-08 15:44:46 +00:00
Merge b21f207b4f into 141d8ef2c4
This commit is contained in:
commit
c23bea499e
13 changed files with 412 additions and 81 deletions
|
|
@ -1,8 +1,11 @@
|
|||
from . import importer
|
||||
from .errors import with_error_handler
|
||||
|
||||
importer.install()
|
||||
|
||||
from django.core.asgi import get_asgi_application # noqa: E402
|
||||
from django.core.asgi import get_asgi_application as dj_get_asgi_application # noqa: E402
|
||||
|
||||
get_asgi_application = with_error_handler(dj_get_asgi_application)
|
||||
|
||||
# this is just for the crazy ones
|
||||
application = get_asgi_application()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import re
|
|||
from django.conf import global_settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from .errors import ConfigurationError, SetupError
|
||||
from .utils import uppercase_attributes
|
||||
from .values import Value, setup_value
|
||||
|
||||
|
|
@ -142,6 +143,13 @@ class Configuration(metaclass=ConfigurationBase):
|
|||
|
||||
@classmethod
|
||||
def setup(cls):
|
||||
exceptions = []
|
||||
for name, value in uppercase_attributes(cls).items():
|
||||
if isinstance(value, Value):
|
||||
setup_value(cls, name, value)
|
||||
try:
|
||||
setup_value(cls, name, value)
|
||||
except ConfigurationError as err:
|
||||
exceptions.append(err)
|
||||
|
||||
if len(exceptions) > 0:
|
||||
raise SetupError(f"Couldn't setup values of configuration {cls.__name__}", exceptions)
|
||||
|
|
|
|||
132
configurations/errors.py
Normal file
132
configurations/errors.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from typing import TYPE_CHECKING, List, Callable
|
||||
from functools import wraps
|
||||
import sys
|
||||
import os
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .values import Value # pragma: no cover
|
||||
|
||||
|
||||
class TermStyles:
|
||||
BOLD = "\033[1m" if os.isatty(sys.stderr.fileno()) else ""
|
||||
RED = "\033[91m" if os.isatty(sys.stderr.fileno()) else ""
|
||||
END = "\033[0m" if os.isatty(sys.stderr.fileno()) else ""
|
||||
|
||||
|
||||
def extract_explanation_lines_from_value(value_instance: 'Value') -> List[str]:
|
||||
result = []
|
||||
|
||||
if value_instance.help_text is not None:
|
||||
result.append(f"Help: {value_instance.help_text}")
|
||||
|
||||
if value_instance.help_reference is not None:
|
||||
result.append(f"Reference: {value_instance.help_reference}")
|
||||
|
||||
if value_instance.destination_name is not None:
|
||||
result.append(f"{value_instance.destination_name} is taken from the environment variable "
|
||||
f"{value_instance.full_environ_name} as a {type(value_instance).__name__}")
|
||||
|
||||
if value_instance.example_generator is not None:
|
||||
result.append(f"Example value: '{value_instance.example_generator()}'")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
"""
|
||||
Exception that gets raised when a configuration class cannot be set up by the importer
|
||||
"""
|
||||
|
||||
def __init__(self, msg: str, child_errors: List['ConfigurationError'] = None) -> None:
|
||||
"""
|
||||
:param step_verb: Which step the importer tried to perform (e.g. import, setup)
|
||||
:param configuration_path: The full module path of the configuration that was supposed to be set up
|
||||
:param child_errors: Optional child configuration errors that caused this error
|
||||
"""
|
||||
super().__init__(msg)
|
||||
self.child_errors = child_errors or []
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
"""
|
||||
Base error class that is used to indicate that something went wrong during configuration.
|
||||
|
||||
This error type (and subclasses) is caught and pretty-printed by django-configurations so that an end-user does not
|
||||
see an unwieldy traceback but instead a helpful error message.
|
||||
"""
|
||||
|
||||
def __init__(self, main_error_msg: str, explanation_lines: List[str]) -> None:
|
||||
"""
|
||||
:param main_error_msg: Main message that describes the error.
|
||||
This will be displayed before all *explanation_lines* and in the traceback (although tracebacks are normally
|
||||
not rendered)
|
||||
:param explanation_lines: Additional lines of explanations which further describe the error or give hints on
|
||||
how to fix it.
|
||||
"""
|
||||
super().__init__(main_error_msg)
|
||||
self.main_error_msg = main_error_msg
|
||||
self.explanation_lines = explanation_lines
|
||||
|
||||
|
||||
class ValueRetrievalError(ConfigurationError):
|
||||
"""
|
||||
Exception that is raised when errors occur during the retrieval of a Value by one of the `Value` classes.
|
||||
This can happen when the environment variable corresponding to the value is not defined.
|
||||
"""
|
||||
|
||||
def __init__(self, value_instance: "Value", *extra_explanation_lines: str):
|
||||
"""
|
||||
:param value_instance: The `Value` instance which caused the generation of this error
|
||||
:param extra_explanation_lines: Extra lines that will be appended to `ConfigurationError.explanation_lines`
|
||||
in addition the ones automatically generated from the provided *value_instance*.
|
||||
"""
|
||||
super().__init__(
|
||||
f"Value of {value_instance.destination_name} could not be retrieved from environment",
|
||||
list(extra_explanation_lines) + extract_explanation_lines_from_value(value_instance)
|
||||
)
|
||||
|
||||
|
||||
class ValueProcessingError(ConfigurationError):
|
||||
"""
|
||||
Exception that is raised when a dynamic Value failed to be processed by one of the `Value` classes after retrieval.
|
||||
|
||||
Processing could be i.e. converting from string to a native datatype or validation.
|
||||
"""
|
||||
|
||||
def __init__(self, value_instance: "Value", raw_value: str, *extra_explanation_lines: str):
|
||||
"""
|
||||
:param value_instance: The `Value` instance which caused the generation of this error
|
||||
:param raw_value: The raw value that was retrieved from the environment and which could not be processed further
|
||||
:param extra_explanation_lines: Extra lines that will be prepended to `ConfigurationError.explanation_lines`
|
||||
in addition the ones automatically generated from the provided *value_instance*.
|
||||
"""
|
||||
error = f"{value_instance.destination_name} was given an invalid value"
|
||||
if hasattr(value_instance, "message"):
|
||||
error += ": " + value_instance.message.format(raw_value)
|
||||
|
||||
explanation_lines = list(extra_explanation_lines) + extract_explanation_lines_from_value(value_instance)
|
||||
explanation_lines.append(f"'{raw_value}' was received but that is invalid")
|
||||
|
||||
super().__init__(error, explanation_lines)
|
||||
|
||||
|
||||
def with_error_handler(callee: Callable) -> Callable:
|
||||
"""
|
||||
A decorator which is designed to wrap django entry points with an error handler so that django-configuration
|
||||
originated errors can be caught and rendered to the user in a readable format.
|
||||
"""
|
||||
|
||||
@wraps(callee)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return callee(*args, **kwargs)
|
||||
except SetupError as e:
|
||||
msg = f"{str(e)}"
|
||||
for child_error in e.child_errors:
|
||||
msg += f"\n * {child_error.main_error_msg}"
|
||||
for explanation_line in child_error.explanation_lines:
|
||||
msg += f"\n - {explanation_line}"
|
||||
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
return wrapper
|
||||
59
configurations/example_generators.py
Normal file
59
configurations/example_generators.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from typing import Callable
|
||||
import secrets
|
||||
import base64
|
||||
|
||||
import django
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
if django.VERSION[0] > 3 or \
|
||||
(django.VERSION[0] == 3 and django.VERSION[1] >= 2):
|
||||
# RANDOM_STRING_CHARS was only introduced in django 3.2
|
||||
from django.utils.crypto import RANDOM_STRING_CHARS
|
||||
else:
|
||||
RANDOM_STRING_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" # pragma: no cover
|
||||
|
||||
|
||||
def gen_django_secret_key() -> str:
|
||||
"""
|
||||
Generate a cryptographically secure random string that can safely be used as a SECRET_KEY in django
|
||||
"""
|
||||
return get_random_secret_key()
|
||||
|
||||
|
||||
def gen_random_string(length: int, allowed_chars: str = RANDOM_STRING_CHARS) -> Callable[[], str]:
|
||||
"""
|
||||
Create a parameterized generator which generates a cryptographically secure random string of the given length
|
||||
containing the given characters.
|
||||
"""
|
||||
|
||||
def _gen_random_string() -> str:
|
||||
return get_random_string(length, allowed_chars)
|
||||
|
||||
return _gen_random_string
|
||||
|
||||
|
||||
def gen_bytes(length: int, encoding: str) -> Callable[[], str]:
|
||||
"""
|
||||
Create a parameterized generator which generates a cryptographically secure random assortments of bytes of the given
|
||||
length and encoded in the given format
|
||||
|
||||
:param length: How many bytes should be generated. Not how long the encoded string will be.
|
||||
:param encoding: How the generated bytes should be encoded.
|
||||
Accepted values are "base64", "base64_urlsafe" and "hex" (case is ignored)
|
||||
"""
|
||||
encoding = encoding.lower()
|
||||
if encoding not in ("base64", "base64_urlsafe", "hex"):
|
||||
raise ValueError(f"Cannot gen_bytes with encoding '{encoding}'. Valid encodings are 'base64', 'base64_urlsafe'"
|
||||
f" and 'hex'")
|
||||
|
||||
def _gen_bytes() -> str:
|
||||
b = secrets.token_bytes(length)
|
||||
if encoding == "base64":
|
||||
return base64.standard_b64encode(b).decode("ASCII")
|
||||
elif encoding == "base64_urlsafe":
|
||||
return base64.urlsafe_b64encode(b).decode("ASCII")
|
||||
elif encoding == "hex":
|
||||
return b.hex().upper()
|
||||
|
||||
return _gen_bytes
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
from . import importer
|
||||
from .errors import with_error_handler
|
||||
|
||||
importer.install()
|
||||
|
||||
from django.core.servers.fastcgi import runfastcgi # noqa
|
||||
from django.core.servers.fastcgi import dj_runfastcgi # noqa
|
||||
|
||||
runfastcgi = with_error_handler(dj_runfastcgi)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from django.conf import ENVIRONMENT_VARIABLE as SETTINGS_ENVIRONMENT_VARIABLE
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.management import base
|
||||
|
||||
from .errors import SetupError, ConfigurationError
|
||||
from .utils import uppercase_attributes, reraise
|
||||
from .values import Value, setup_value
|
||||
|
||||
|
|
@ -149,10 +150,10 @@ class ConfigurationLoader:
|
|||
|
||||
try:
|
||||
cls = getattr(mod, self.name)
|
||||
except AttributeError as err: # pragma: no cover
|
||||
reraise(err, "Couldn't find configuration '{0}' "
|
||||
"in module '{1}'".format(self.name,
|
||||
mod.__package__))
|
||||
except AttributeError: # pragma: no cover
|
||||
raise SetupError(f"Couldn't find configuration '{self.name}' in module {mod.__package__}.\n"
|
||||
f"Hint: '{self.name}' is taken from the environment variable '{CONFIGURATION_ENVIRONMENT_VARIABLE}'"
|
||||
f"and '{mod.__package__}' from the environment variable '{SETTINGS_ENVIRONMENT_VARIABLE}'.")
|
||||
try:
|
||||
cls.pre_setup()
|
||||
cls.setup()
|
||||
|
|
@ -172,6 +173,10 @@ class ConfigurationLoader:
|
|||
self.name))
|
||||
cls.post_setup()
|
||||
|
||||
except SetupError:
|
||||
raise
|
||||
except ConfigurationError as err:
|
||||
raise SetupError(f"Couldn't setup configuration '{cls_path}'", [err])
|
||||
except Exception as err:
|
||||
reraise(err, "Couldn't setup configuration '{0}'".format(cls_path))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
from . import importer
|
||||
from .errors import with_error_handler
|
||||
|
||||
importer.install(check_options=True)
|
||||
|
||||
from django.core.management import (execute_from_command_line, # noqa
|
||||
call_command)
|
||||
from django.core.management import (execute_from_command_line as dj_execute_from_command_line, # noqa
|
||||
call_command as dj_call_command)
|
||||
|
||||
execute_from_command_line = with_error_handler(dj_execute_from_command_line)
|
||||
call_command = with_error_handler(dj_call_command)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import ast
|
|||
import copy
|
||||
import decimal
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError, ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from .errors import ValueRetrievalError, ValueProcessingError
|
||||
from .utils import getargspec
|
||||
|
||||
|
||||
|
|
@ -58,8 +58,8 @@ class Value:
|
|||
return instance
|
||||
|
||||
def __init__(self, default=None, environ=True, environ_name=None,
|
||||
environ_prefix='DJANGO', environ_required=False,
|
||||
*args, **kwargs):
|
||||
environ_prefix='DJANGO', environ_required=False, example_generator=None,
|
||||
help_text=None, help_reference=None, *args, **kwargs):
|
||||
if isinstance(default, Value) and default.default is not None:
|
||||
self.default = copy.copy(default.default)
|
||||
else:
|
||||
|
|
@ -70,6 +70,10 @@ class Value:
|
|||
self.environ_prefix = environ_prefix
|
||||
self.environ_name = environ_name
|
||||
self.environ_required = environ_required
|
||||
self.destination_name = None
|
||||
self.help_text = help_text
|
||||
self.help_reference = help_reference
|
||||
self.example_generator = example_generator
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
|
@ -86,33 +90,46 @@ class Value:
|
|||
# Compatibility with python 2
|
||||
__nonzero__ = __bool__
|
||||
|
||||
def full_environ_name(self, name):
|
||||
@property
|
||||
def full_environ_name(self):
|
||||
"""
|
||||
The full name of the environment variable (including prefix and capitalization) from which this value should be
|
||||
retrieved
|
||||
"""
|
||||
if self.environ_name:
|
||||
environ_name = self.environ_name
|
||||
else:
|
||||
environ_name = name.upper()
|
||||
environ_name = self.destination_name.upper()
|
||||
if self.environ_prefix:
|
||||
environ_name = '{0}_{1}'.format(self.environ_prefix, environ_name)
|
||||
return environ_name
|
||||
|
||||
def setup(self, name):
|
||||
"""
|
||||
Set up this value instance by retrieving the configured value from the environment and converting it to a native
|
||||
python data type
|
||||
|
||||
:param name: Destination name for which this value is used.
|
||||
For example in the scenario of `DEBUG = Value()` in a `Configuration` subclass, this would be `DEBUG`.
|
||||
"""
|
||||
self.destination_name = name
|
||||
value = self.default
|
||||
if self.environ:
|
||||
full_environ_name = self.full_environ_name(name)
|
||||
full_environ_name = self.full_environ_name
|
||||
if full_environ_name in os.environ:
|
||||
value = self.to_python(os.environ[full_environ_name])
|
||||
elif self.environ_required:
|
||||
raise ValueError('Value {0!r} is required to be set as the '
|
||||
'environment variable {1!r}'
|
||||
.format(name, full_environ_name))
|
||||
raise ValueRetrievalError(self)
|
||||
self.value = value
|
||||
return value
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
"""
|
||||
Convert the given value of a environment variable into an
|
||||
Convert the given value of an environment variable into an
|
||||
appropriate Python representation of the value.
|
||||
This should be overriden when subclassing.
|
||||
This should be overridden when subclassing.
|
||||
|
||||
:param value: The value that should be converted to a python representation
|
||||
"""
|
||||
return value
|
||||
|
||||
|
|
@ -127,19 +144,18 @@ class BooleanValue(Value):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.default not in (True, False):
|
||||
raise ValueError('Default value {0!r} is not a '
|
||||
'boolean value'.format(self.default))
|
||||
if not self.environ_required and self.default not in (True, False):
|
||||
raise ImproperlyConfigured('Default value {0!r} is not a '
|
||||
'boolean value'.format(self.default))
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
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 {0!r}'.format(value))
|
||||
raise ValueProcessingError(self, value)
|
||||
|
||||
|
||||
class CastingMixin:
|
||||
|
|
@ -159,21 +175,21 @@ class CastingMixin:
|
|||
else:
|
||||
error = 'Cannot use caster of {0} ({1!r})'.format(self,
|
||||
self.caster)
|
||||
raise ValueError(error)
|
||||
raise ImproperlyConfigured(error)
|
||||
try:
|
||||
arg_names = getargspec(self._caster)[0]
|
||||
self._params = {name: kwargs[name] for name in arg_names if name in kwargs}
|
||||
except TypeError:
|
||||
self._params = {}
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
try:
|
||||
if self._params:
|
||||
return self._caster(value, **self._params)
|
||||
else:
|
||||
return self._caster(value)
|
||||
except self.exception:
|
||||
raise ValueError(self.message.format(value))
|
||||
raise ValueProcessingError(self, value)
|
||||
|
||||
|
||||
class IntegerValue(CastingMixin, Value):
|
||||
|
|
@ -182,10 +198,10 @@ class IntegerValue(CastingMixin, Value):
|
|||
|
||||
class PositiveIntegerValue(IntegerValue):
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
int_value = super().to_python(value)
|
||||
if int_value < 0:
|
||||
raise ValueError(self.message.format(value))
|
||||
raise ValueProcessingError(self, value, f"Value needs to be positive or zero but {int_value} isn't")
|
||||
return int_value
|
||||
|
||||
|
||||
|
|
@ -209,7 +225,7 @@ class SequenceValue(Value):
|
|||
converter = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
msg = 'Cannot interpret {0} item {{0!r}} in {0} {{1!r}}'
|
||||
msg = 'Cannot interpret {0} item in {0} {{0!r}}'
|
||||
self.message = msg.format(self.sequence_type.__name__)
|
||||
self.separator = kwargs.pop('separator', ',')
|
||||
converter = kwargs.pop('converter', None)
|
||||
|
|
@ -231,10 +247,10 @@ class SequenceValue(Value):
|
|||
try:
|
||||
converted_values.append(self.converter(value))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(self.message.format(value, value))
|
||||
raise ValueProcessingError(self, self.separator.join(sequence))
|
||||
return self.sequence_type(converted_values)
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
split_value = [v.strip() for v in value.strip().split(self.separator)]
|
||||
# removing empty items
|
||||
value_list = self.sequence_type(filter(None, split_value))
|
||||
|
|
@ -270,7 +286,7 @@ class SingleNestedSequenceValue(SequenceValue):
|
|||
return self.sequence_type(converted_sequences)
|
||||
return self.sequence_type(super()._convert(items))
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
split_value = [
|
||||
v.strip() for v in value.strip().split(self.seq_separator)
|
||||
]
|
||||
|
|
@ -296,13 +312,11 @@ class BackendsValue(ListValue):
|
|||
try:
|
||||
import_string(value)
|
||||
except ImportError as err:
|
||||
raise ValueError(err).with_traceback(sys.exc_info()[2])
|
||||
raise ValueProcessingError(self, value) from err
|
||||
return value
|
||||
|
||||
|
||||
class SetValue(ListValue):
|
||||
message = 'Cannot interpret set item {0!r} in set {1!r}'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.default is None:
|
||||
|
|
@ -310,7 +324,7 @@ class SetValue(ListValue):
|
|||
else:
|
||||
self.default = set(self.default)
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
return set(super().to_python(value))
|
||||
|
||||
|
||||
|
|
@ -324,16 +338,16 @@ class DictValue(Value):
|
|||
else:
|
||||
self.default = dict(self.default)
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
value = super().to_python(value)
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
evaled_value = ast.literal_eval(value)
|
||||
except ValueError:
|
||||
raise ValueError(self.message.format(value))
|
||||
raise ValueProcessingError(self, value)
|
||||
if not isinstance(evaled_value, dict):
|
||||
raise ValueError(self.message.format(value))
|
||||
raise ValueProcessingError(self, value)
|
||||
return evaled_value
|
||||
|
||||
|
||||
|
|
@ -350,16 +364,19 @@ class ValidationMixin:
|
|||
elif callable(self.validator):
|
||||
self._validator = self.validator
|
||||
else:
|
||||
raise ValueError('Cannot use validator of '
|
||||
'{0} ({1!r})'.format(self, self.validator))
|
||||
raise ImproperlyConfigured('Cannot use validator of '
|
||||
'{0} ({1!r})'.format(self, self.validator))
|
||||
if self.default:
|
||||
self.to_python(self.default)
|
||||
try:
|
||||
self.to_python(self.default)
|
||||
except ValueProcessingError as e:
|
||||
raise ImproperlyConfigured(e.main_error_msg) from e
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
try:
|
||||
self._validator(value)
|
||||
except ValidationError:
|
||||
raise ValueError(self.message.format(value))
|
||||
except ValidationError as e:
|
||||
raise ValueProcessingError(self, value, f"Validation failed: {e.message}")
|
||||
else:
|
||||
return value
|
||||
|
||||
|
|
@ -397,7 +414,7 @@ class PathValue(Value):
|
|||
value = super().setup(name)
|
||||
value = os.path.expanduser(value)
|
||||
if self.check_exists and not os.path.exists(value):
|
||||
raise ValueError('Path {0!r} does not exist.'.format(value))
|
||||
raise ValueProcessingError(self, value, f"Path {value} does not exist")
|
||||
return os.path.abspath(value)
|
||||
|
||||
|
||||
|
|
@ -408,13 +425,13 @@ class SecretValue(Value):
|
|||
kwargs['environ_required'] = True
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.default is not None:
|
||||
raise ValueError('Secret values are only allowed to '
|
||||
'be set as environment variables')
|
||||
raise ImproperlyConfigured('Secret values are only allowed to '
|
||||
'be set as environment variables')
|
||||
|
||||
def setup(self, name):
|
||||
value = super().setup(name)
|
||||
if not value:
|
||||
raise ValueError('Secret value {0!r} is not set'.format(name))
|
||||
raise ValueRetrievalError(self)
|
||||
return value
|
||||
|
||||
|
||||
|
|
@ -448,7 +465,7 @@ class DictBackendMixin(Value):
|
|||
else:
|
||||
self.default = self.to_python(self.default)
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str):
|
||||
value = super().to_python(value)
|
||||
return {self.alias: value}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from . import importer
|
||||
from .errors import with_error_handler
|
||||
|
||||
importer.install()
|
||||
|
||||
from django.core.wsgi import get_wsgi_application # noqa: E402
|
||||
from django.core.wsgi import get_wsgi_application as dj_get_wsgi_application # noqa: E402
|
||||
|
||||
get_wsgi_application = with_error_handler(dj_get_wsgi_application)
|
||||
|
||||
# this is just for the crazy ones
|
||||
application = get_wsgi_application()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ from configurations import Configuration, values
|
|||
class Base(Configuration):
|
||||
# Django settings for test_project project.
|
||||
|
||||
DEBUG = values.BooleanValue(True, environ=True)
|
||||
DEBUG = values.BooleanValue(True, environ=True, help_text="Enables or disables django debug mode",
|
||||
help_reference="https://docs.djangoproject.com/en/dev/ref/settings/#debug")
|
||||
|
||||
ADMINS = (
|
||||
# ('Your Name', 'your_email@example.com'),
|
||||
|
|
|
|||
43
tests/test_error_handling.py
Normal file
43
tests/test_error_handling.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from configurations.errors import ValueRetrievalError, SetupError, with_error_handler
|
||||
from configurations.values import Value
|
||||
|
||||
|
||||
class ErrorHandlingTestCase(TestCase):
|
||||
def test_help_text_in_explanation_lines(self):
|
||||
value_instance = Value(help_text="THIS IS A TEST")
|
||||
exception = ValueRetrievalError(value_instance)
|
||||
self.assertIn("Help: THIS IS A TEST", exception.explanation_lines)
|
||||
|
||||
def test_help_reference_in_explanation_lines(self):
|
||||
value_instance = Value(help_reference="https://example.com")
|
||||
exception = ValueRetrievalError(value_instance)
|
||||
self.assertIn("Reference: https://example.com", exception.explanation_lines)
|
||||
|
||||
def test_example_in_explanation_lines(self):
|
||||
value_instance = Value(example_generator=lambda: "test")
|
||||
exception = ValueRetrievalError(value_instance)
|
||||
self.assertIn("Example value: 'test'", exception.explanation_lines)
|
||||
|
||||
def test_error_handler_rendering(self):
|
||||
# setup
|
||||
with patch("configurations.errors.sys.stderr", new=io.StringIO()) as mock:
|
||||
def inner():
|
||||
try:
|
||||
value_instance = Value(environ_required=True)
|
||||
value_instance.setup("TEST")
|
||||
except ValueRetrievalError as err:
|
||||
raise SetupError("This is a test exception", [err])
|
||||
|
||||
# execution
|
||||
with_error_handler(inner)()
|
||||
|
||||
# verification
|
||||
self.assertEqual(mock.getvalue().strip(),
|
||||
"This is a test exception\n"
|
||||
" * Value of TEST could not be retrieved from environment\n"
|
||||
" - TEST is taken from the environment variable DJANGO_TEST as a Value")
|
||||
35
tests/test_example_generators.py
Normal file
35
tests/test_example_generators.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import base64
|
||||
from django.test import TestCase
|
||||
from configurations.example_generators import gen_bytes, gen_random_string, gen_django_secret_key
|
||||
|
||||
|
||||
class ExampleGeneratorsTestCase(TestCase):
|
||||
def test_generators_dont_raise_exceptions(self):
|
||||
for gen in [gen_bytes(64, "hex"), gen_bytes(64, "base64"), gen_bytes(64, "base64_urlsafe"),
|
||||
gen_random_string(16, "ab"), gen_random_string(5),
|
||||
gen_django_secret_key]:
|
||||
with self.subTest(gen.__name__):
|
||||
gen()
|
||||
|
||||
# gen_django_secret_key() and gen_random_string() are not tested beyond the above general test case
|
||||
# because they are just wrappers around existing django utilities.
|
||||
# They are thus assumed to work.
|
||||
|
||||
def test_gen_bytes(self):
|
||||
with self.subTest("base64"):
|
||||
result = gen_bytes(64, "base64")()
|
||||
b = base64.standard_b64decode(result.encode("ASCII"))
|
||||
self.assertEqual(len(b), 64)
|
||||
|
||||
with self.subTest("base64_urlsafe"):
|
||||
result = gen_bytes(64, "base64_urlsafe")()
|
||||
b = base64.urlsafe_b64decode(result.encode("ASCII"))
|
||||
self.assertEqual(len(b), 64)
|
||||
|
||||
with self.subTest("hex"):
|
||||
result = gen_bytes(64, "hex")()
|
||||
b = bytes.fromhex(result)
|
||||
self.assertEqual(len(b), 64)
|
||||
|
||||
with self.subTest("invalid"):
|
||||
self.assertRaises(ValueError, gen_bytes, 64, "invalid")
|
||||
|
|
@ -2,6 +2,7 @@ import decimal
|
|||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django import VERSION as DJANGO_VERSION
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
|
@ -15,8 +16,9 @@ from configurations.values import (Value, BooleanValue, IntegerValue,
|
|||
RegexValue, PathValue, SecretValue,
|
||||
DatabaseURLValue, EmailURLValue,
|
||||
CacheURLValue, BackendsValue,
|
||||
CastingMixin, SearchURLValue,
|
||||
setup_value, PositiveIntegerValue)
|
||||
SearchURLValue, PositiveIntegerValue, CastingMixin,
|
||||
setup_value)
|
||||
from configurations.errors import ValueRetrievalError, ValueProcessingError
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -58,6 +60,19 @@ class ValueTests(TestCase):
|
|||
|
||||
self.assertEqual(repr(value), repr('override'))
|
||||
|
||||
def test_environ_required(self):
|
||||
for ValueClass in (Value, BooleanValue, IntegerValue,
|
||||
FloatValue, DecimalValue, ListValue,
|
||||
TupleValue, SingleNestedTupleValue,
|
||||
SingleNestedListValue, SetValue,
|
||||
DictValue, URLValue, EmailValue, IPValue,
|
||||
RegexValue, PathValue, SecretValue,
|
||||
DatabaseURLValue, EmailURLValue,
|
||||
CacheURLValue, BackendsValue,
|
||||
SearchURLValue, PositiveIntegerValue):
|
||||
value = ValueClass(environ_required=True)
|
||||
self.assertRaises(ValueRetrievalError, value.setup, "TEST")
|
||||
|
||||
def test_value_truthy(self):
|
||||
value = Value('default')
|
||||
self.assertTrue(bool(value))
|
||||
|
|
@ -110,7 +125,7 @@ class ValueTests(TestCase):
|
|||
self.assertTrue(bool(value.setup('TEST')))
|
||||
|
||||
def test_boolean_values_faulty(self):
|
||||
self.assertRaises(ValueError, BooleanValue, 'false')
|
||||
self.assertRaises(ImproperlyConfigured, BooleanValue, 'false')
|
||||
|
||||
def test_boolean_values_false(self):
|
||||
value = BooleanValue(True)
|
||||
|
|
@ -121,7 +136,7 @@ class ValueTests(TestCase):
|
|||
def test_boolean_values_nonboolean(self):
|
||||
value = BooleanValue(True)
|
||||
with env(DJANGO_TEST='nonboolean'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_boolean_values_assign_false_to_another_booleanvalue(self):
|
||||
value1 = BooleanValue(False)
|
||||
|
|
@ -134,30 +149,30 @@ class ValueTests(TestCase):
|
|||
with env(DJANGO_TEST='2'):
|
||||
self.assertEqual(value.setup('TEST'), 2)
|
||||
with env(DJANGO_TEST='noninteger'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_positive_integer_values(self):
|
||||
value = PositiveIntegerValue(1)
|
||||
with env(DJANGO_TEST='2'):
|
||||
self.assertEqual(value.setup('TEST'), 2)
|
||||
with env(DJANGO_TEST='noninteger'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
with env(DJANGO_TEST='-1'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_float_values(self):
|
||||
value = FloatValue(1.0)
|
||||
with env(DJANGO_TEST='2.0'):
|
||||
self.assertEqual(value.setup('TEST'), 2.0)
|
||||
with env(DJANGO_TEST='noninteger'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_decimal_values(self):
|
||||
value = DecimalValue(decimal.Decimal(1))
|
||||
with env(DJANGO_TEST='2'):
|
||||
self.assertEqual(value.setup('TEST'), decimal.Decimal(2))
|
||||
with env(DJANGO_TEST='nondecimal'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_failing_caster(self):
|
||||
self.assertRaises(ImproperlyConfigured, FailingCasterValue)
|
||||
|
|
@ -194,7 +209,7 @@ class ValueTests(TestCase):
|
|||
def test_list_values_converter_exception(self):
|
||||
value = ListValue(converter=int)
|
||||
with env(DJANGO_TEST='2,b'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_tuple_values_default(self):
|
||||
value = TupleValue()
|
||||
|
|
@ -292,21 +307,21 @@ class ValueTests(TestCase):
|
|||
with env(DJANGO_TEST=''):
|
||||
self.assertEqual(value.setup('TEST'), {})
|
||||
with env(DJANGO_TEST='spam'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_email_values(self):
|
||||
value = EmailValue('spam@eg.gs')
|
||||
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')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_url_values(self):
|
||||
value = URLValue('http://eggs.spam')
|
||||
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')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_url_values_with_no_default(self):
|
||||
value = URLValue() # no default
|
||||
|
|
@ -314,7 +329,7 @@ class ValueTests(TestCase):
|
|||
self.assertEqual(value.setup('TEST'), 'http://spam.eggs')
|
||||
|
||||
def test_url_values_with_wrong_default(self):
|
||||
self.assertRaises(ValueError, URLValue, 'httb://spam.eggs')
|
||||
self.assertRaises(ImproperlyConfigured, URLValue, 'httb://spam.eggs')
|
||||
|
||||
def test_ip_values(self):
|
||||
value = IPValue('0.0.0.0')
|
||||
|
|
@ -323,14 +338,14 @@ class ValueTests(TestCase):
|
|||
with env(DJANGO_TEST='::1'):
|
||||
self.assertEqual(value.setup('TEST'), '::1')
|
||||
with env(DJANGO_TEST='spam.eggs'):
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_regex_values(self):
|
||||
value = RegexValue('000--000', 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')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_path_values_with_check(self):
|
||||
value = PathValue()
|
||||
|
|
@ -339,7 +354,7 @@ class ValueTests(TestCase):
|
|||
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')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_path_values_no_check(self):
|
||||
value = PathValue(check_exists=False)
|
||||
|
|
@ -354,17 +369,17 @@ class ValueTests(TestCase):
|
|||
|
||||
def test_secret_value(self):
|
||||
# no default allowed, only environment values are
|
||||
self.assertRaises(ValueError, SecretValue, 'default')
|
||||
self.assertRaises(ImproperlyConfigured, SecretValue, 'default')
|
||||
|
||||
value = SecretValue()
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueRetrievalError, value.setup, 'TEST')
|
||||
with env(DJANGO_SECRET_KEY='123'):
|
||||
self.assertEqual(value.setup('SECRET_KEY'), '123')
|
||||
|
||||
value = SecretValue(environ_name='FACEBOOK_API_SECRET',
|
||||
environ_prefix=None,
|
||||
late_binding=True)
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueRetrievalError, value.setup, 'TEST')
|
||||
with env(FACEBOOK_API_SECRET='123'):
|
||||
self.assertEqual(value.setup('TEST'), '123')
|
||||
|
||||
|
|
@ -411,6 +426,7 @@ class ValueTests(TestCase):
|
|||
'EMAIL_HOST_PASSWORD': 'password',
|
||||
'EMAIL_HOST_USER': 'user@domain.com',
|
||||
'EMAIL_PORT': 587,
|
||||
'EMAIL_TIMEOUT': None,
|
||||
'EMAIL_USE_SSL': False,
|
||||
'EMAIL_USE_TLS': True})
|
||||
with env(EMAIL_URL='console://'):
|
||||
|
|
@ -421,15 +437,16 @@ class ValueTests(TestCase):
|
|||
'EMAIL_HOST_PASSWORD': None,
|
||||
'EMAIL_HOST_USER': None,
|
||||
'EMAIL_PORT': None,
|
||||
'EMAIL_TIMEOUT': None,
|
||||
'EMAIL_USE_SSL': False,
|
||||
'EMAIL_USE_TLS': False})
|
||||
with env(EMAIL_URL='smtps://user@domain.com:password@smtp.example.com:wrong'): # noqa: E501
|
||||
self.assertRaises(ValueError, value.setup, 'TEST')
|
||||
self.assertRaises(ValueProcessingError, value.setup, 'TEST')
|
||||
|
||||
def test_cache_url_value(self):
|
||||
cache_setting = {
|
||||
'default': {
|
||||
'BACKEND': 'django_redis.cache.RedisCache',
|
||||
'BACKEND': 'django_redis.cache.RedisCache' if DJANGO_VERSION[0] < 4 else 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': 'redis://host:6379/1',
|
||||
}
|
||||
}
|
||||
|
|
@ -445,11 +462,11 @@ class ValueTests(TestCase):
|
|||
value.setup('TEST')
|
||||
self.assertEqual(cm.exception.args[0], 'Unknown backend: "wrong"')
|
||||
with env(CACHE_URL='redis://user@host:port/1'):
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
with self.assertRaises(ValueProcessingError) as cm:
|
||||
value.setup('TEST')
|
||||
self.assertEqual(
|
||||
cm.exception.args[0],
|
||||
"Cannot interpret cache URL value 'redis://user@host:port/1'")
|
||||
"TEST was given an invalid value: Cannot interpret cache URL value 'redis://user@host:port/1'")
|
||||
|
||||
def test_search_url_value(self):
|
||||
value = SearchURLValue()
|
||||
|
|
@ -468,7 +485,7 @@ class ValueTests(TestCase):
|
|||
self.assertEqual(value.setup('TEST'), backends)
|
||||
|
||||
backends = ['non.existing.Backend']
|
||||
self.assertRaises(ValueError, BackendsValue, backends)
|
||||
self.assertRaises(ValueProcessingError, BackendsValue, backends)
|
||||
|
||||
def test_tuple_value(self):
|
||||
value = TupleValue(None)
|
||||
|
|
@ -503,6 +520,7 @@ class ValueTests(TestCase):
|
|||
'EMAIL_HOST_PASSWORD': 'password',
|
||||
'EMAIL_HOST_USER': 'user@domain.com',
|
||||
'EMAIL_PORT': 587,
|
||||
'EMAIL_TIMEOUT': None,
|
||||
'EMAIL_USE_SSL': False,
|
||||
'EMAIL_USE_TLS': True
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue