transport additional information in error types

This commit is contained in:
Finn-Thorben Sell 2022-03-24 10:13:49 +01:00
parent ece1521acd
commit 7de2615441
No known key found for this signature in database
GPG key ID: A78A03C25A3A3825
2 changed files with 128 additions and 67 deletions

View file

@ -3,36 +3,86 @@ from functools import wraps
import sys
import os
class ValueRetrievalError(ValueError):
"""
Exception is raised when errors occur during the retrieval of a dynamic Value.
This can happen when the environment variable corresponding to the value is not defined.
"""
def __init__(self, value_instance: 'configurations.values.Value', error_msg: str) -> None:
super().__init__(error_msg)
self.value_instance = value_instance
self.error_msg = error_msg
class ValueProcessingError(ValueError):
"""
Exception that is raised when a dynamic Value failed to be processed after retrieval.
Processing could be i.e. converting from string to a native datatype but also validation.
"""
def __init__(self, value_instance: 'configurations.values.Value', str_value: str, error_msg: str) -> None:
super().__init__(error_msg)
self.value_instance = value_instance
self.str_value = str_value
self.error_msg = error_msg
if TYPE_CHECKING:
from .values import Value
class TermStyles:
BOLD = "\033[1m"
RED = "\033[91m"
END = "\033[0m"
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 ""
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*.
"""
explanation_lines = list(extra_explanation_lines)
if value_instance.destination_name is not None:
explanation_lines.append(f"{value_instance.destination_name} is taken from the environment variable "
f"{value_instance.full_environ_name} as a {type(value_instance).__name__}")
super().__init__(
f"Value of {value_instance.destination_name} could not be retrieved from environment",
explanation_lines
)
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)
if value_instance.destination_name is not None:
explanation_lines.append(f"{value_instance.destination_name} is taken from the environment variable "
f"{value_instance.full_environ_name} as a {type(value_instance).__name__}")
explanation_lines.append(f"'{raw_value}' was received but that is invalid")
super().__init__(error, explanation_lines)
def with_error_handler(callee: Callable) -> Callable:
@ -40,14 +90,15 @@ 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 (ValueRetrievalError, ValueProcessingError) as e:
msg = "{}{}{}".format(TermStyles.RED + TermStyles.BOLD, e, TermStyles.END) \
if os.isatty(sys.stderr.fileno()) \
else str(e)
except ConfigurationError as e:
msg = "{}{}{}".format(TermStyles.RED + TermStyles.BOLD, e, TermStyles.END)
for line in e.explanation_lines:
msg += f"\n {line}"
print(msg, file=sys.stderr)
return wrapper

View file

@ -2,13 +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 .errors import ValueRetrievalError, ValueProcessingError, ConfigurationError
from .utils import getargspec
@ -71,6 +70,7 @@ class Value:
self.environ_prefix = environ_prefix
self.environ_name = environ_name
self.environ_required = environ_required
self.destination_name = None
def __str__(self):
return str(self.value)
@ -87,33 +87,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 ValueRetrievalError(self, '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
@ -132,15 +145,14 @@ class BooleanValue(Value):
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 ValueProcessingError(self, value, 'Cannot interpret '
'boolean value {0!r}'.format(value))
raise ValueProcessingError(self, value)
class CastingMixin:
@ -167,14 +179,14 @@ class CastingMixin:
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 ValueProcessingError(self, value, self.message.format(value))
raise ValueProcessingError(self, value)
class IntegerValue(CastingMixin, Value):
@ -183,10 +195,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 ValueProcessingError(self, value, self.message.format(value))
raise ValueProcessingError(self, value, f"Value needs to be positive or zero but {int_value} isn't")
return int_value
@ -210,7 +222,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)
@ -232,10 +244,10 @@ class SequenceValue(Value):
try:
converted_values.append(self.converter(value))
except (TypeError, ValueError):
raise ValueProcessingError(self, self.separator.join(sequence), 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))
@ -271,7 +283,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)
]
@ -297,13 +309,11 @@ class BackendsValue(ListValue):
try:
import_string(value)
except ImportError as err:
raise ValueProcessingError(self, value, str(err)).with_traceback(sys.exc_info()[2])
raise ValueProcessingError(self, value)
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:
@ -311,7 +321,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))
@ -325,16 +335,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 ValueProcessingError(self, value, self.message.format(value))
raise ValueProcessingError(self, value)
if not isinstance(evaled_value, dict):
raise ValueProcessingError(self, value, self.message.format(value))
raise ValueProcessingError(self, value)
return evaled_value
@ -357,13 +367,13 @@ class ValidationMixin:
try:
self.to_python(self.default)
except ValueProcessingError as e:
raise ImproperlyConfigured(e.error_msg)
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 ValueProcessingError(self, value, self.message.format(value))
except ValidationError as e:
raise ValueProcessingError(self, value, f"Validation failed: {e.message}")
else:
return value
@ -401,7 +411,7 @@ class PathValue(Value):
value = super().setup(name)
value = os.path.expanduser(value)
if self.check_exists and not os.path.exists(value):
raise ValueProcessingError(self, value, 'Path {0!r} does not exist.'.format(value))
raise ValueProcessingError(self, value, f"Path {value} does not exist")
return os.path.abspath(value)
@ -418,7 +428,7 @@ class SecretValue(Value):
def setup(self, name):
value = super().setup(name)
if not value:
raise ValueRetrievalError(self, 'Secret value {0!r} is not set'.format(name))
raise ValueRetrievalError(self)
return value
@ -452,7 +462,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}