Streamline Code Reviews With pre-commit

Bikeshedding

Whether intentional or not, one unfortunate reality in software engineering is bikeshedding. This usually manifests itself during code reviews as debates over trivial things like how much whitespace we should use instead of focusing on the actual changes in the code.

Just about everyone who writes Python full time is aware of PEP-8, but it is still difficult to enforce these things uniformly and at all times.

It’s possible to completely avoid these debates during the actual code review, by using pre-commit. Pre-commit is a set of git hooks which, as the name implies, run before your changes are committed in git. If a check done by pre-commit fails, then you won’t be able to commit your changes.

Installing pre-commit

As usual we can use pip to install pre-commit.

pip install pre-commit

In a Django project this makes more sense as a requirement in development environments. This is something we can achieve easily using pip-tools.

Configuration

Configuration for pip-tools is done in a single file named .pre-commit-config.yaml. Create this file at the root of your repository.

This is our .pre-commit-config.yaml:

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v3.4.0
  hooks:
  - id: check-added-large-files
  - id: check-case-conflict
  - id: check-json
  - id: check-merge-conflict
  - id: check-toml
  - id: check-yaml
  - id: destroyed-symlinks
  - id: debug-statements
  - id: detect-private-key
  - id: end-of-file-fixer
  - id: mixed-line-ending
  - id: trailing-whitespace
- repo: https://github.com/pycqa/isort
  rev: 5.8.0
  hooks:
  - id: isort
- repo: https://github.com/psf/black
  rev: 20.8b1
  hooks:
  - id: black

The workhorse in our configuration is the popular code formatter black. It enforces a sub-set of PEP-8 with the side goal of producing the smallest diffs possible. When paired with pre-commit, it automatically updates your code to be PEP-8 compliant. All you need to do is add its changes to git’s staging area and then commit again.

The other interesting hooks are:

Most of the hooks have self-explanatory names. A complete list of hooks that come with pre-commit by default are in the official docs

You can also use 3rd party hooks, by pointing to their git repo like what we did for isort and black.

Installing pre-commit’s hooks

Installing the pre-commit Python package doesn’t install the hooks in your repository.

We need to run a separate command for this.

pre-commit install

The first time you run this will take a little while.

This doesn’t install the hooks in your virtualenv so if you want the ability to manually invoke the hooks, you should add them to your development requirements as well.

A contrived example

Let’s say you want to introduce this a new Dog model to your repository.

from django.db.models import ForeignKey
from django.db.models import PROTECT
from django.db.models import CharField, Model



class Dog(Model):




    owner = ForeignKey("auth.User", on_delete=PROTECT, blank=True, null=True)
    name = CharField(max_length=200, unique=True)

The obvious nit to pick here is the number of lines between the class name and field named owner. The imports could use a bit of cleaning up as well.

As usual, we’ll stage the changes and then commit.

git add dogs/models.py
git commit -m "Completely unhelpful commit message."

This will trigger pre-commit and its hooks.

Check for added large files..............................................Passed
Check for case conflicts.................................................Passed
Check JSON...........................................(no files to check)Skipped
Check for merge conflicts................................................Passed
Check Toml...........................................(no files to check)Skipped
Check Yaml...........................................(no files to check)Skipped
Detect Destroyed Symlinks................................................Passed
Debug Statements (Python)................................................Passed
Detect Private Key.......................................................Passed
Fix End of Files.........................................................Passed
Mixed line ending........................................................Passed
Trim Trailing Whitespace.................................................Passed
isort....................................................................Failed
- hook id: isort
- files were modified by this hook

Fixing /Users/django-cookbook/src/animals-api/animals/dogs/models.py

black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted animals/dogs/models.py
All done! ✨ 🍰 ✨
1 file reformatted.

If we run git diff, this is what we get:

-from django.db.models import PROTECT
+from django.db.models import (
+    PROTECT,
+    CharField,
+    ForeignKey,
+    Model,
+)
-from django.db.models import ForeignKey
-from django.db.models import PROTECT
-from django.db.models import CharField, Model

 class Dog(Model):

-
-
-
-
-
     owner = models.ForeignKey("auth.User", on_delete=PROTECT, blank=True, null=True)

At this point we just need to stage the changes and commit again.

Running pre-commit on an existing repository

Ideally pre-commit is used right after a repository is made so it enforces your code style for all the changes. This is rarely the case in the real world, but we have the option of manually running all the hooks.

pre-commit run --all-files

Final thoughts

We’ve demonstrated very basic use cases for pre-commit in this recipe. This is probably enough for most projects, but if you want more advanced hooks(like requiring better commit messages) it’s possible to roll your own. The official documentation for pre-commit is a good place to start for a deep dive of its capabilities.

If you found this useful and would like to get updates on new posts, please subscribe to our newsletter and follow us on Twitter @DjangoCookbook.