A @context decorator for Django
Even the happiest Django developers with the most ardent love of class-based views can get tired
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 ...
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.
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.
@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
__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()
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
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)
_context_fields attribute already exists, this can have two reasons: either we're not the first
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!