A @context decorator for Django

Even the happiest Django developers with the most ardent love of class-based views can get tired of writing super().get_context_data(**kwargs), only to add a couple of already-implemented properties or methods to the template context.

django-context-decorator provides, as the name may hint at, a decorator named @context. You can wrap it around both methods and properties, including Django's cached property, and it will add the property's value or the method's return value to your template context:

from django.views.generic import TemplateView
from django_context_decorator import context


class MyView(TemplateView):

    @context
    @cached_property
    def formset(self):  # The formset will be available in your template
        ...

The package

Sold and not interested in the nitty-gritty details? pip install django-context-decorator, and you're ready to go – if you use Python 3.6+. Due to reasons I'll go into below, this decorator doesn't work with Python 3.5-.

The source is available on GitHub with complete tests and documentation.

The source code is only 50 lines long, plus another 150 lines of tests. Usually I wouldn't package tiny components such as this, but there were a couple of reasons to go for it, regardless:

  • I personally want to use this library in several projects, and while I'm used to copying code between them, it's never pleasant.
  • Other people expressed that they'd want to use it once it was a finished project, and once code gets spread more widely, it's much nicer to have code, docs, and tests in one place.
  • Lazy person that I am, I would like to see this functionality in Django (not least of all because some hackiness could be removed then), and pitching a tiny convenience feature is easier if you have atomic code + docs + tests.

Implementation details

I implemented this together with Raphael at DjangoCon Europe 2019, with some help from other devs there, most notably Tim (thanks!) when I ran into a couple of obscure bugs on first usage.

Our @context decorator is implemented as a class to make use of the __set_name__ method, which is a Python 3.6 feature (by way of PEP487). It allows you to modify the parent of a method at class load time on every attribute of the class. This is quite a handy feature – without it, we'd have to resort to a mixin class for all views using @context. __set_name__ also removes the need for many or even most metaclasses out there.

The following is an abridged version of context – only some scaffolding was removed.

class context:

    def __set_name__(self, owner, name):

        if not hasattr(owner, '_context_fields'):
            owner._context_fields = set()

Since cached_property overrides the property with its first evaluated result, and we can't trivially set attributes on that without forcing early evaluation of the property. If we didn't need to support cached_property, we could set an attribute on the property/method, but instead, we construct a _context_fields set and collect all names of context fields there.

        elif getattr(owner, '_context_copied', '') is not owner:
            setattr(owner, '_context_copied', owner)
            owner._context_fields = copy.deepcopy(owner._context_fields)
        owner._context_fields.add(name)

If the _context_fields attribute already exists, this can have two reasons: either we're not the first context decorated field in this class, or this class inherits from another class with context decorated fields – in this case we have to deepcopy the existing set, and note who last updated the field. Otherwise, there is a very unpleasant propagation of context fields across model inheritance.

To figure out if _context_fields needs to be deepcopied, we set the _context_copied attribute on our parent class to, well, itself – and if it's not set to itself, we know that we have to run deepcopy. (We could go for owner.__class__ instead, but that would run into issues once we have two classes with the same name in our inheritance tree. While that is not a common thing to have, especially with drop-in-replacements this can and will happen.)

        if not getattr(owner, '_context_patched', False):
            old_get_context_data = owner.get_context_data

            def new_get_context_data(_self, **kwargs):
                result = old_get_context_data(_self, **kwargs)
                for name in _self._context_fields:
                    attr = getattr(_self, name)
                    if callable(attr):
                        attr = attr()
                    result.setdefault(name, attr)
                return result

            owner.get_context_data = new_get_context_data
            owner._context_patched = True

Now we need to replace get_context_data with a version that collects all of our attributes and adds them to its return value, calling them if they are callable. We set a flag once we have patched get_context_data, to avoid having a method call stack as deep as the amount of @context fields on this class. Don't recurse(-ish) and drive.

    def __get__(self, instance, cls=None):
        return (
            self.func.__get__(instance, cls)
            if hasattr(self.func, '__get__')
            else self.func
        )

To work with both callables and properties, we override __get__, and we're done. ✨

If you have thoughts about this, ping me on the fediverse, on Twitter, or send me an email!