django-upgrade Mega Release 1.11.0

Some busy bees have contributed their fixes.

I just released version 1.11.0 of django-upgrade, a tool for automatically upgrading your Django project code. This release contains a lot of new features and fixes, thanks to new contributors including those at the Djangocon Europe sprints. Let’s look at the top changes.

New admin.site.register() fixer

Originally the Django admin required you to first define a ModelAdmin class, and then later register Model classes to use it with admin.site.register():

from django.contrib import admin


class MyCustomAdmin(admin.ModelAdmin):
    ...


admin.site.register(MyModel1, MyCustomAdmin)
admin.site.register(MyModel2, MyCustomAdmin)

With this format, the register() calls can become quite separated from the admin classes. In more complicated scenarios, you might also miss exactly which Model classes are registered to an admin class.

To solve these problems, Django 1.7 (September 2014!) added the admin.register() class decorator:

from django.contrib import admin


@admin.register(MyModel1, MyModel2)
class MyCustomAdmin(admin.ModelAdmin):
    ...

django-upgrade can now automatically upgrade from the first form to the second:

 from django.contrib import admin

+@admin.register(MyModel1, MyModel2)
 class MyCustomAdmin(admin.ModelAdmin):
     ...

-admin.site.register(MyModel1, MyCustomAdmin)
-admin.site.register(MyModel2, MyCustomAdmin)

This works with various forms of admin.site.register() calls, and with GIS admin aliases as well.

Thanks to Thibaut Decombe for contributing this feature.

New @admin.action() and @admin.display() fixers

The Django admin has two special kinds of functions: actions and display functions. The admin interface displays actions in a dropdown, and display functions as table columns. In order to customize this display, you can set some attributes on these functions. For example, to label an action:

from django.contrib import admin


def make_published(modeladmin, request, queryset):
    ...


make_published.short_description = "Publish articles"

Attaching attributes to functions is a bit error-prone, as typos don’t produce any errors, and type checkers complain about it. And, like with admin.site.register(), the attribute setting can be spaced out far from the function.

To solve these problems, Django 3.2 introduced new @admin.action and @admin.display decorators. These decorators provide a neat interface to set the underlying attributes. For example, the above action cna be written with:

from django.contrib import admin


@admin.action(
    description="Publish articles",
)
def make_published(modeladmin, request, queryset):
    ...

django-upgrade now rewrites action and display functions to use the decorator:

 from django.contrib import admin

+@admin.action(
+    description="Publish articles",
+)
 def make_published(modeladmin, request, queryset):
     ...

-make_published.short_description = "Publish articles"

This applies to both module and class level functions.

Thanks to Nick Pope for contributing the feature to Django 3.2, and for reviewing the new fixer in django-upgrade.

New assertFormError() and assertFormsetError() fixers

Django’s SimpleTestCase class provides the assertFormError() and assertFormsetError() methods for checking form errors. Historically, you’d pass in a response object from the test client:

class ContactTests(TestCase):
    def test_post_no_poop(self):
        response = self.client.post("/contact/", {"message": "💩"})

        self.assertFormError(response, "form", "message", ["Poop emoji disallowed."])

This prevents using these methods for testing forms directly, without the test client. That’s annoying, as testing form classes directly is faster.

To improve this, Django 4.1 deprecated the previous calling convention. Now, instead you should pass the form object. This is a fairly simple change for existing uses, because the form can be pulled from the context:

class ContactTests(TestCase):
    def test_post_no_poop(self):
        response = self.client.post("/contact/", {"message": "💩"})

        self.assertFormError(
            response.context["form"], "message", ["Poop emoji disallowed."]
        )

Indeed this context lookup is what the old forms of the assertion functions do internally.

Updating a test suite for this deprecation would be quite laborious. Thankfully, django-upgrade has got your back:

 class ContactTests(TestCase):
     def test_post_no_poop(self):
         response = self.client.post("/contact/", {"message": "💩"})

         self.assertFormError(
-            response, "form", "message", ["Poop emoji disallowed."]
+            response.context["form"], "message", ["Poop emoji disallowed."]
         )

Enjoy.

Changes to the request.headers fixer

Looking up headers in Django request objects historically required using the old-school prefixed, capitalized form of the header name in request.META:

accept_encoding = request.META.get("HTTP_ACCEPT_ENCODING", "")

These weird names are hard to remember.

Django 2.2 added request.headers as much better interface:

accept_encoding = request.headers.get("accept-encoding", "")

Nice.

django-upgrade has fixed such header lookups since version 1.3.0 (2021-09-22). But this release sees some nice improvements.

First, django-upgrade now also rewrites lookups of the content-length and content-type headers. These two headers have special cased old-style names, without the HTTP_ prefix:

-content_type = request.META.get("CONTENT_TYPE", "")
+content_type = request.headers.get("content-type", "")

Thanks to Christian Bundy for contributing this improvement.

Second, django-upgrade now rewrites use of in / not in to look at headers:

-if "HTTP_USER_AGENT" in request.META:
+if "user-agent" in request.headers:

Thanks to Daan Vielen for this contribution.

Third, django-upgrade now uses lowercase header names when it rewrites your code. This is done to match the HTTP/2 specification, which uses lowercase for header names throughout. Header lookups in Django are case-insensitive, so this is a matter of style.

This decision was made somewhat democratically with a Twitter poll, where 70% of respondents voted for lowercase header names. Thanks to all who responded.

A future version will lowercase header names in existing use of request.headers, as decided in a follow-up poll.

Fourth, django-upgrade now tries to preserve the quote style of the header lookup, rather than always use single quotes. If your code uses double quotes, you’ll see less “churn” when django-upgrade changes your code, as e.g. Black won’t need to change the quote style back. This change also applies to the URL path() fixer.

Thanks to Kevin Marsh for this contribution.

Various other improvements

The new version also includes a bunch of small bug fixes and usability improvements. See the changelog for the full list.

Thanks to many people who contributed towards these: Benjamin Bach, Thibaut Decombe, Joseph Zammit, and Daan Vielen.

A successful sprint

Many of these improvements were written at the Djangocon Europe 2022 sprints. Also, a whole bunch of other participants did valuable work testing the main branch of django-upgrade on various projects, flushing out bugs. Thank you very much to everyone who participated!

Upgrade now plz

Use the new version of django-upgrade on your projects today!

If you use pre-commit:

$ pre-commit autoupdate --repo https://github.com/adamchainz/django-upgrade

If you install with pip:

$ pip install -U django-upgrade

Please report any issues you encounter.

Fin

Modernize all the things!

—Adam


If your Django project’s long test runs bore you, I wrote a book that can help.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: