How to write Flake8 plugins 😍

· by

Contents

Really important parts of code reviews are almost impossible to automate: Architectural decisions and logical bugs. They are too customized to your codebase; too specific for the pull request.

However, many comments in code reviews are not like that. They are about simple style decisions, common minor mistakes, and misconceptions. They are valuable as well, but they distract the reviewer from the harder parts. The linting system Flake8 allows you to write plugins that automatically capture those simple things. You can execute them in your CI pipeline and thus never need to think about them again.

In this article, you will learn how to create a Flake8 plugin. As an example, we will create a plugin which recognizes the pattern

# Bad
not (a == b)

and thus can suggest using the following instead:

# Good
a != b

Cartoon by Geek-and-Poke (Oliver Widder)Cartoon by Geek-and-Poke (Oliver Widder)

About Flake8

Flake8 is a linter which only checks rules. It does not change the code. Every rule has a message and a code. The built-in rule codes begin either with E (Error) or with W (Warning). After that, a 3-digit number follows:

  • E101: indentation contains mixed spaces and tabs
  • E111: indentation is not a multiple of four
  • E112: expected an indented block

One can select the rules which one wants to check on a prefix basis:

# Check all rules beginning with "E1" and nothing else
flake8 --select E1 .

Alternatively, one can blacklist rules:

# Check all rules except the ones beginning with E1 or W512
flake8 --ignore E1,W512 .

Plugins need a 3-character prefix. For my plugin flake8-simplify, I chose SIM as a prefix.

The 3-character prefix should not start with E or W as people might want to completely block Flake8 W-rules.

Comic by Geek and Poke (Oliver Widder)Comic by Geek and Poke (Oliver Widder)

The Flake8 Plugin Skeleton

Cookiecutter is a command-line utility that allows you to create a project from scratch by using a template. Install it via:

pip install cookiecutter

To create your Flake8 plugin template, use:

cookiecutter https://github.com/MartinThoma/cookiecutter-flake8-plugin

The Flake8 Plugin class

You need to create a plugin class like this:

import sys


if sys.version_info < (3, 8):  # pragma: no cover (<PY38)
    # Third party
    import importlib_metadata
else:  # pragma: no cover (PY38+)
    # Core Library
    import importlib.metadata as importlib_metadata


class Plugin:
    name = __name__
    version = importlib_metadata.version(__name__)

    def __init__(self, tree: ast.AST):
        self._tree = tree

    def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
        visitor = Visitor()
        visitor.visit(self._tree)

        for line, col, msg in visitor.errors:
            yield line, col, msg, type(self)

You need to point to that class in the setup.cfg:

[options.entry_points]
flake8.extension =
    AWE=flake8_awesome:Plugin

Just replace AWE with the 3-character code you want to have for your rules. We are, of course, not there yet. The visitor class still needs to be implemented. But to understand the visitor, we need to understand Python's AST.

Understanding Python's AST

An Abstract Syntax Tree (AST) is another representation of the code.

Install astpretty to understand what the AST looks like for a piece of code you’re interested in:

pip install astpretty

Here is how you use it:

$ astpretty --no-show-offsets /dev/stdin <<< "not a == b"

Module(
    body=[
        Expr(
            value=UnaryOp(
                op=Not(),
                operand=Compare(
                    left=Name(id='a', ctx=Load()),
                    ops=[Eq()],
                    comparators=[Name(id='b', ctx=Load())],
                ),
            ),
        ),
    ],
    type_ignores=[],
)

Next, we need to recognize that pattern.

A Flake8 visitor class

The logic of the linter is in the visitor class. The visitor gets called for every node in the AST. It has various methods which are based on the operations you see within astpretty, e.g. visit_UnaryOp. This method receives an ast.UnaryOp with which you can do whatever you want. More often than not, this will not be of the pattern you need. In this example, you need to look for op == Not(), operand == Compare, and ops == [Eq()].

This is how it’s done:

import ast
from typing import Any, DefaultDict, Generator, List, Tuple, Type


class Visitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.errors: List[Tuple[int, int, str]] = []

    def visit_UnaryOp(self, node: ast.UnaryOp) -> None:
        self.errors += _get_not_equal_calls(node)
        self.generic_visit(node)


def _get_not_equal_calls(node: ast.UnaryOp) -> List[Tuple[int, int, str]]:
    """Get a list of all calls where a unary 'not' is used for an equality."""
    errors: List[Tuple[int, int, str]] = []
    if not isinstance(node.op, ast.Not) or not isinstance(node.operand, ast.Compare):
        return errors
    if len(node.operand.ops) != 1 or not isinstance(node.operand.ops[0], ast.Eq):
        return errors
    comparison = node.operand
    left = astor.to_source(comparison.left).strip()
    right = astor.to_source(comparison.comparators[0]).strip()
    errors.append((node.lineno, node.col_offset, SIM201.format(left=left, right=right)))

    return errors

I like astor's to_source to get a string back from the AST node, just like it was in the source code.

Testing Flake8 plugins

A big shoutout to Anthony Sottile. Before his video (linked below), I had no clue how to test Flake8 plugins. Thank you ❤️

It’s trivial once you know it: You create a string that contains a line or multiple lines of code. You pass it to ast.parse to get the abstract syntax tree. That one can be passed to your plugin, which should generate some output. That output can then be checked:

# Core Library
import ast
from typing import Set

# First party
from flake8_simplify import Plugin


def _results(code: str) -> Set[str]:
    """Apply the plugin to the given code."""
    tree = ast.parse(code)
    plugin = Plugin(tree)
    return {f"{line}:{col} {msg}" for line, col, msg, _ in plugin.run()}


def test_trivial_case():
    """Check the plugin's output for no code."""
    assert _results("") == set()

A non-trivial test looks like this:

def test_unary_not_equality():
    ret = _results("not a == b")
    assert ret == {("1:0 SIM201 Used 'not a == b' instead of 'a != b'")}

Complete example

This is part of flake8-simplify 0.2.0. I’ve pinned the version so that you can see the code in the simple form it has right now.

Please make me proud and automate your reviews — make the following comic true for Flake8 plugins as well :-)

Comic by geek-and-poke (Oliver Widder)Comic by geek-and-poke (Oliver Widder)

See also

I can highly recommend the following video by Anthony Sottile (LinkedIn, GitHub). He is the awesome guy who develops pyupgrade, blacken-docs, and astpretty: