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!