From 7de261544178d44bbe521f1c709092f0fe3a6b99 Mon Sep 17 00:00:00 2001 From: Finn-Thorben Sell Date: Thu, 24 Mar 2022 10:13:49 +0100 Subject: [PATCH] transport additional information in error types --- configurations/errors.py | 113 ++++++++++++++++++++++++++++----------- configurations/values.py | 82 +++++++++++++++------------- 2 files changed, 128 insertions(+), 67 deletions(-) diff --git a/configurations/errors.py b/configurations/errors.py index 3a59b51..da8bbe8 100644 --- a/configurations/errors.py +++ b/configurations/errors.py @@ -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 diff --git a/configurations/values.py b/configurations/values.py index 2051718..8829d43 100644 --- a/configurations/values.py +++ b/configurations/values.py @@ -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}