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")