Recently I wrote a script for git that runs Pylint on every modified Python file before each commit. If Pylint's rating is relatively positive, business continues as usual; however if the rating is below a pre-defined threshold, the commit will be rejected.

I find this useful because its the simple things that are easiest for me to lose track of and it also helps enforce a styleguide. Forgetting to remove imports that aren't being used anymore is a common occurence for me. Pylint catches that easily. More important than that is a consistent style in the code that we write at WWU Housing. We have high turnover. Not because WWU Housing is a bad place to develop — its the opposite — but because half of us are students who are graduating all the time and only stick around for a year or two. Every line of code we write has the potential to become the worst kind of legacy code.

Since this script is just a pre-commit hook for git, it already fits in to our workflow. We don't have to stop and think about running Pylint to reap the benefits.

Get it working

To install this script, just locate it at $REPO/.git/hooks/pre-commit where $REPO is the path to your local git repository and make sure it is executable.

Here is the code:

#!/usr/local/bin/python
"""
A pre-commit hook for git that uses Pylint for automated code review.

If any python file's rating falls below the ``PYLINT_PASS_THRESHOLD``, this
script will return nonzero and the commit will be rejected.

This script must be located at ``$REPO/.git/hooks/pre-commit`` and be
executable.

Copyright 2009 Nick Fitzgerald - MIT Licensed.
"""
import os
import re
import sys

from subprocess import Popen, PIPE

# Threshold for code to pass the Pylint test. 10 is the highest score Pylint
# will give to any peice of code.
PYLINT_PASS_THRESHOLD = 7

def main():
    """Checks your git commit with Pylint!"""
    # Run the git command that gets the filenames of every file that has been
    # locally modified since the last commit.
    sub = Popen("git diff --staged --name-only HEAD".split(),
                stdout=PIPE)
    sub.wait()

    # Filter out non-python or deleted files.
    py_files_changed = [file
                        for file in [f.strip() for f in sub.stdout.readlines()]
                        if (file.endswith(".py") and os.path.exists(file))
                            or is_py_script(file)]

    # Run Pylint on each file, collect the results, and display them for the
    # user.
    results = {}
    for file in py_files_changed:
        pylint = Popen(("pylint -f text %s" % file).split(),
                       stdout=PIPE)
        pylint.wait()

        output = pylint.stdout.read()
        print output

        results_re = re.compile(r"Your code has been rated at ([\d\.]+)/10")
        results[file] = float(results_re.findall(output)[0])

    # Display a summary of the results (if any files were checked).
    if len(results.values()) > 0:
        print "==============================================="
        print "Final Results:"
        for file in results:
            result = results[file]
            grade = "FAIL" if result < PYLINT_PASS_THRESHOLD else "pass"
            print "[ %s ] %s: %.2f/10" % (grade, file, result)

    # If any of the files failed the Pylint test, exit nonzero and stop the
    # commit from continuing.
    if any([(result < PYLINT_PASS_THRESHOLD)
            for result in results.values()]):
        print "git: fatal: commit failed, Pylint tests failing."
        sys.exit(1)
    else:
        sys.exit(0)


def is_py_script(filename):
    """Returns True if a file is a python executable."""
    if not os.access(filename, os.X_OK):
        return False
    else:
        try:
            first_line = open(filename, "r").next().strip()
            return "#!" in first_line and "python" in first_line
        except StopIteration:
            return False


if __name__ == "__main__":
    main()

Final note: if you ever find that you need to side step the script, just commit with the --no-verify flag and the script will not be run.