Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve error related end-user experience #333

Draft
wants to merge 15 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion configurations/asgi.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 9 additions & 1 deletion configurations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
132 changes: 132 additions & 0 deletions configurations/errors.py
Original file line number Diff line number Diff line change
@@ -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 changes: 59 additions & 0 deletions configurations/example_generators.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion configurations/fastcgi.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 9 additions & 4 deletions configurations/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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))

Expand Down
8 changes: 6 additions & 2 deletions configurations/management.py
Original file line number Diff line number Diff line change
@@ -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)
Loading