3
\$\begingroup\$

Django implements a System Check framework that enables apps to perform checks on settings, runtime, etc. When creating an app, one can plug checks with the framework by following some requirements, one of them being how errors are reported.

The integrations are actually ok, but the error management system is a bit too manual. The recommendation is that errors are created from base classes of CheckMessage, such as Error. It's also available for warnings, info, etc. Here's an example:

Error(
    'an error',
    hint='A hint.',
    obj=checked_object,
    id='myapp.E001',
)

What bothers me with this is that error codes are tracked manually and spread out through the code. If two developers are working in the same rep, it'll be easy to conflict error codes, something that could easily pass code reviews, merge conflicts, etc. Initially I thought that this could only be for tutorial purposes, but turns out that's actually how they do it.

The problem with this approach is the numbering system. The id is prefixed with the app name, which is OK, followed by a level indication (e.g. E for error), both of which are easily trackable. But the numbering system can easily generate a conflict (e.g. two developers create the same code in different modules of the same app).

I wanted to have an approach that would enable the error codes to be generated serially and in a centralized way, so that the conflicts are avoided, but one is stuck with this system behind the curtains. Here's what I tried:

from collections import defaultdict
from django.core.checks import messages
from django.core.checks import CheckMessage


class MessageRegistry(object):
    """ Keeps a registry of Django CheckMessages identifiers.

    Django implements a System Check framework used for validating projects. The
    framework can be used by third-parties to improve on Django's functionality,
    while at the same time implementing checks of the same kind.

    However, the error reporting system relies on hardcoded message identifiers
    that must be tracked throughout the code. This is an anti-paradigm, since it
    implies that, sooner or later, codes will collide, possibly without anyone
    noticing (e.g. easily missed in code reviews).

    A MessageRegistry keeps track of registered messages, in order, so that
    their identifiers are generated in sequence. This is useful to prevent error
    codes from colliding within the same app domain.

    This class is mostly used internally, and the methods register, make, and
    get_code are the preferred way to consume the API. That's because this is a
    helper class, since the registry is actually handled by a default instance
    of this class.

    Although this class implements the methods with the same names as the API,
    the difference is that the methods act on the instance they are bound to
    and the API entry points act on a default instance.
    """

    levels = {
        messages.DEBUG: 'D',
        messages.INFO: 'I',
        messages.WARNING: 'W',
        messages.ERROR: 'E',
        messages.CRITICAL: 'C'
    }

    def __init__(self):

        self.counters = defaultdict(lambda: defaultdict(int))
        self.codes = defaultdict(lambda: defaultdict(str))

    def _level_identifier(self, level):

        # Make sure the given level is OK
        if level not in self.levels:
            raise KeyError(f'Unrecognized message level "{level}" registered as a message')

        return self.levels[level]

    def register(self, code, app, level):
        """ Registers a message code.

        Given a code and an (app, level) pair, this method register the message
        and increments the corresponding counter, assigning it an identifier
        corresponding to the current counter position. If the given code already
        exists, KeyError is raised. Otherwise the counter is incremented and the
        identifier is generated for when the code is referenced, either through
        get_code or make. The method doesn't return anything.
        """

        # Codes can only be registered once
        if code in self.codes:
            raise KeyError(f'The message code "{code}" is already registered')

        # Count the registrations, segregated by app and level
        self.counters[app][level] += 1

        # Register an accessor
        self.codes[code] = level, f'{app}.{self._level_identifier(level)}{self.counters[app][level]:03}'

    def get_code(self, code):
        """ Returns the generated code for a given message code.

        The message code must have previously been registered. If it's not,
        KeyError is raised.
        """

        if code not in self.codes:
            raise KeyError(f'Unrecognized code "{code}" could not be mapped to a message')

        return self.codes[code]

    def make(self, code, message, hint, object=None):
        """ Creates a CheckMessage using the code as a reference.

        The code argument is a code that has previously been registered. If the
        code is not found, KeyError is raised.

        The rest of the arguments are passed verbatin to the CheckMessage
        initializer, with the difference that the code is used to query the
        level (e.g. warning, error, etc) and the generated code reference is
        used as the identifier for the message.
        """

        level, reference = self.get_code(code)

        return CheckMessage(level, message, hint, object, reference)


# Global registry
_message_registry = MessageRegistry()


def register(code, app, level):
    """ Registers a message with a given code.

    The (app, level) pair becomes associated with that code and is used to
    generate the message ID when the code is referenced. When a message is
    registered, a counter is incremented progressively, so message codes are
    sequential.

    If the same code is registered more than once, KeyError is raised,
    effectively preventing duplications.
    """

    global _message_registry

    _message_registry.register(code, app, level)


def make(code, message, hint, object=None):
    """ Creates a messages from a code and some extra parameters.

    The code must be registered or KeyError is raised. Otherwise, if the code
    was previously registered, the message identifier is generated from the
    (app, level) pair given earlier.
    """

    global _message_registry

    return _message_registry.make(code, message, hint, object)
    

This can be used by creating a, say, errors.py file where every contributor can register errors in an append-only fashion. Because the file is append-only, conflicts are easy to track. Here's an example:

from djutils import messages


messages.register('D5A88CC0B3EC', 'djutils', checks.ERROR)
messages.register('0A25CEA3F2C3', 'djutils', checks.WARNING)
messages.register('A84DEF26F5A0', 'djutils', checks.INFO)
messages.register('5DE1BAF3E498', 'djutils', checks.ERROR)
messages.register('FA9544893762', 'djutils', checks.DEBUG)

Referencing the messages is also easy:

messages.make('D5A88CC0B3EC', 'Something failed', 'Try last minute pressure', self)

The registration seems a bit redundant because the pairs (app, level) don't appear significant, but keep in mind that a counter is being tracked. The identifiers can be any convention, but in this case I use the last few digits of uuidgen because those are highly unique and easy to generate.

Upsides of this approach:

  • Message codes are sequential, avoiding conflicts, as initially intended
  • It's easy to make the numbering immutable from code reviews; e.g. it's easy to notice that a message was deleted. If a message is not being used anymore, it should stick around, so that the numbering is kept. Because the IDs are unreadable, it also disincentivizes changes, as it should, to keep the list deterministic
  • It's easy to maintain; e.g. developers generate a code, use it, and move on. If a new code is needed, they register another one.
  • It abstracts away Django's details for message creation
  • It works even if the the module is reloaded, since the registry is cleaned and the registrations occur again and in the same order

Downsides:

  • When the message code is being referenced (with messages.make) you can't tell the level (error, info, debug, etc) directly from the reference. If you need to know that, you need to check the code in the registry file
  • It feels a bit anti-paradigmatic. Managing error codes is not imperative in terms of functionality, only documentation, which is what message codes are most useful for
  • In order to know a given code order, you need to open a shell and query it with get_code (helper method not implemented yet), since the value is generated at runtime and you can't just read it from the source

What are your thoughts?

\$\endgroup\$
0

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.