Git Hooks and Pylint

December 3rd, 2009

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.

« Previous Entry

Next Entry »

View Comments


Recent Entries


TryParenScript.com

On July 23rd, 2010


In response to "A JavaScript Function Guard"

On July 19th, 2010


My Notes from John Resig's "jQuery Hack Day" Talk

On July 5th, 2010


Announcing Pocco

On June 29th, 2010


Yet Another Lisp

On June 25th, 2010


Recent Happenings: All Play and No Work

On June 21st, 2010


Arguments.callee considered extraneous

On June 2nd, 2010


Javascript, "bind", and "this"

On May 20th, 2010


Class-Based Views and Django

On May 19th, 2010


Introducing Zoolander

On May 2nd, 2010


Creative Commons License

Fork me on GitHub