diff --git a/configurations/asgi.py b/configurations/asgi.py index da9401b..bd744e1 100644 --- a/configurations/asgi.py +++ b/configurations/asgi.py @@ -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() diff --git a/configurations/base.py b/configurations/base.py index 14185fe..d71cf89 100644 --- a/configurations/base.py +++ b/configurations/base.py @@ -4,6 +4,7 @@ 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 @@ def post_setup(cls): @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) diff --git a/configurations/errors.py b/configurations/errors.py new file mode 100644 index 0000000..b339801 --- /dev/null +++ b/configurations/errors.py @@ -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 diff --git a/configurations/example_generators.py b/configurations/example_generators.py new file mode 100644 index 0000000..2da7d09 --- /dev/null +++ b/configurations/example_generators.py @@ -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 diff --git a/configurations/fastcgi.py b/configurations/fastcgi.py index 5f654de..4eeb955 100644 --- a/configurations/fastcgi.py +++ b/configurations/fastcgi.py @@ -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) diff --git a/configurations/importer.py b/configurations/importer.py index e3573f4..df5f6b9 100644 --- a/configurations/importer.py +++ b/configurations/importer.py @@ -8,6 +8,7 @@ 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 @@ def load_module(self, fullname): 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 @@ def load_module(self, fullname): 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)) diff --git a/configurations/management.py b/configurations/management.py index e718ef5..1817a9a 100644 --- a/configurations/management.py +++ b/configurations/management.py @@ -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) diff --git a/configurations/values.py b/configurations/values.py index 8eb3bbc..98d3c26 100644 --- a/configurations/values.py +++ b/configurations/values.py @@ -2,12 +2,12 @@ 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 @@ def __new__(cls, *args, **kwargs): 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 @@ def __init__(self, default=None, environ=True, environ_name=None, 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 @@ def __bool__(self): # 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 @@ def __init__(self, *args, **kwargs): 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 @@ def _convert(self, sequence): 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 @@ def _convert(self, items): 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 @@ def converter(self, value): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def setup(self, name): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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} diff --git a/configurations/wsgi.py b/configurations/wsgi.py index ea157a3..d99d611 100644 --- a/configurations/wsgi.py +++ b/configurations/wsgi.py @@ -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() diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 879a3b8..efa32ab 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -4,7 +4,8 @@ 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'), diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..7f5d0ff --- /dev/null +++ b/tests/test_error_handling.py @@ -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") diff --git a/tests/test_example_generators.py b/tests/test_example_generators.py new file mode 100644 index 0000000..8f8bda5 --- /dev/null +++ b/tests/test_example_generators.py @@ -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") diff --git a/tests/test_values.py b/tests/test_values.py index 2547e50..e3e68d2 100644 --- a/tests/test_values.py +++ b/tests/test_values.py @@ -2,6 +2,7 @@ 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 @@ 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 @@ def test_value_with_default_and_late_binding(self): 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 @@ def test_boolean_values_true(self): 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 @@ def test_boolean_values_false(self): 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 @@ def test_integer_values(self): 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 @@ def test_list_values_custom_converter(self): 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 @@ def test_dict_values_default(self): 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 @@ def test_url_values_with_no_default(self): 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 @@ def test_ip_values(self): 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 @@ def test_path_values_with_check(self): 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 @@ def test_path_values_no_check(self): 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 @@ def test_email_url_value(self): '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 @@ def test_email_url_value(self): '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 @@ def test_cache_url_value(self): 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 @@ def test_backend_list_value(self): 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 Target: 'EMAIL_HOST_PASSWORD': 'password', 'EMAIL_HOST_USER': 'user@domain.com', 'EMAIL_PORT': 587, + 'EMAIL_TIMEOUT': None, 'EMAIL_USE_SSL': False, 'EMAIL_USE_TLS': True })