Django: Custom http decorator variant
Motivation
At ReHome, we want to make sure that every view is decorated with the
@require_http_methods decorator. In the article on
getting all views in a test, I
covered the "test that every view" part; now, we want to cover the "is
decorated" part.
This should probably be a linter rule, since it can be fully determined by looking at the plain-text Python file that containst the view. Is the decorator applied to the view? If so, it passes; if not, it fails.
However, implementing this as a linter rule is pretty awful. At ReHome we use ruff for our linter, which simply does not allow for custom rules at all. I looked into implementing it in Pylint, but that requires delving into the AST, and would require switching tooling away from a tool that generally works great.
Unfortunately, this looks like something that's just easier to implement as a test, even if that adds test time for something that should be a linter rule. Ah well.
Related example
The @login_required and @login_not_required decorators set an attribute on
the view function called login_required, easily enough. So you can test
whether a view has been decorated with one of these functions by simply testing
the view function's login_required attribute:
# In the views file:
@login_not_required
def view_func(request):
"""The simplest possible view function to test"""
return TemplateResponse(request, ...)
# In the tests file:
def test_some_view_has_login_not_required_decorator(...) -> None:
assert hasattr(view_func, "login_required") and not view_func.login_required
But, Django's built-in @require_http_methods function doesn't have an
equivalent indicator that it's been applied to a function.
Re-wrap the view function
It's easy enough to add an indicator attribute, but to do that, we're going to
need to rewrite the require_http_methods view. Since all views are just
functions, we don't need to touch any of the internal logic; we can actually
just call the decorator directly as a higher-order function.
The result is very simple:
from collections.abc import Callable
from django.views.decorators.http import (
require_http_methods as django_require_http_methods,
)
def require_http_methods(request_methods_list: list[str]) -> Callable:
def decorator(view: Callable) -> Callable:
wrapped = django_require_http_methods(request_methods_list)(view)
wrapped.request_methods = request_methods_list
return wrapped
return decorator
That's extremely simple
Yes, it is!
I am writing this article mostly because I've seen my interns at times be completely baffled by lines of code like this. I think it may be that a lot of people, especially relatively new Python people, don't particularly understand the idea of higher-order functions or of decorators.
The test
Now that we've bound the view function with this attribute, the complete test is also extremely straightforward:
def test_local_views_are_decorated_with_require_http_methods(
get_views,
) -> None:
views = get_views(local=True)
for view in views:
assert hasattr(view.fn, "allowed_methods")