TSLint for TypeScript and Clippy for Rust have lints to warn about code that assigns and then immediately returns the assignment.

TSLint will warn with an external lint, no-unnecessary-local-variable.

function foo() {
  const x = "bar"
  return x
}

And Clippy will warn us as well with the let_and_return lint.

fn foo() -> String {
    let x = String::from("bar");
    x
}

But Python linters Pyflakes and Pylint do not warn about similarly problematic code.

So let’s build a lint for it.

We’ll base our lint on Flake8 as Pyflakes doesn’t have plugins and Pylint has performance issues.

Lint examples

We want the following cases to be true:

# error
def foo() -> str:
    x = "bar"
    return x

# allowed because of tuple unpacking
def foo() -> str:
    x, _ = bar()
    return x

# allowed
def foo() -> str:
    return "bar"

Implementing the lint

First we can inspect the AST for one of our examples.

ast.dump(ast.parse("def foo(): x = 'bar'; return x"))

# if we format the returned string we get this:
Module(body=[
    FunctionDef(
        name="foo",
        args=arguments(
            args=[],
            vararg=None,
            kwonlyargs=[],
            kw_defaults=[],
            kwarg=None,
            defaults=[]
        ),
        body=[
            Assign(
                targets=[Name(id="x", ctx=Store())],
                value=Str(s="bar")
            ),
            Return(value=Name(id="x", ctx=Load()))
        ],
        decorator_list=[],
        returns=None
    )
])

Basically, we want to check if the Return() statement of a FunctionDef() has the same value Name() as the second to last statement. We also want to ensure that we check that the second to last statement is a Name() assignment and not a Tuple().

The lint should look something like this:

def is_assign_and_return(func: ast.FunctionDef) -> Optional[Tuple[int, int, str, str]]:
    # assign and return can only occur with at least two statements
    if len(func.body) >= 2:
        return_stmt = func.body[-1]
        if isinstance(return_stmt, ast.Return):
            assign_stmt = func.body[-2]
            if isinstance(assign_stmt, ast.Assign):
                # only assigned to a single variable
                if len(assign_stmt.targets) == 1 and isinstance(
                    assign_stmt.targets[0], ast.Name
                ):
                    if isinstance(return_stmt.value, ast.Name):
                        # check that assigned variable is the same one being returned
                        if return_stmt.value.id == assign_stmt.targets[0].id:
                            return (
                                return_stmt.lineno,
                                return_stmt.col_offset,
                                "assignment and return is not allowed",
                                "AssignAndReturnCheck"
                            )
    return None


func = ast.parse("def foo(): x = 'bar'; return x").body[0]
assert is_assign_and_return(func) is not None

Creating a Flake8 plugin

So our function works, now we just need to hook into Flake8.

The basic structure of a Flake8 plugin is a class with a run() method and some setup in the setup.py.

from typing import NamedTuple


class ErrorTuple(NamedTuple):
    lineno: int
    col_offset: int
    message: str
    type: "YourLint"


class YourLint:
    name = "name-of-your-lint"
    version = __version__

    def __init__(self, tree: ast.Module) -> None:
        """
        you can specify more parameters for this and Flake8 will pass them in.
        see: http://flake8.pycqa.org/en/latest/plugin-development/plugin-parameters.html
        """
        self.tree = tree

    def run(self) -> Iterable[ErrorTuple]:
        """
        Flake8 calls this and expects it to `yield` errors
        """
        raise NotImplementedError

Now we we just need to create a function to return all the errors from a tree.

Looks like a job for ast.NodeVisitor.

class AssignAndReturnVisitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.errors: List[ErrorLoc] = []

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """
        run checker function and track error if found
        """
        error = is_assign_and_return(node)
        if error is not None:
            self.errors.append(error)

This allows us to keep track of all the errors that occur in the entire ast.Module and means we don’t need to search the AST ourselves to find FunctionDef()s.

All we have to do is just hook it up to the Flake8 lint class.

class AssignAndReturnCheck:
    name = "flake8-assign-and-return"
    version = __version__

    def __init__(self, tree: ast.Module) -> None:
        self.tree = tree

    def run(self) -> Iterable[Tuple]:
        visitor = AssignAndReturnVisitor()

        visitor.visit(self.tree)

        yield from visitor.errors

Putting it all together

from typing import Optional, List, NamedTuple, Iterable, Tuple
from functools import partial
import ast


class ErrorLoc(NamedTuple):
    """
    location of the lint infraction
    """

    lineno: int
    col_offset: int

    message: str
    type: "AssignAndReturnCheck"


class AssignAndReturnVisitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.errors: List[ErrorLoc] = []

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """
        run checker function and track error if found
        """
        error = is_assign_and_return(node)
        if error is not None:
            self.errors.append(error)


def is_assign_and_return(func: ast.FunctionDef) -> Optional[ErrorLoc]:
    """
    check a FunctionDef for assignment and return where a user assigns to a
    variable and returns that variable instead of just returning
    """
    # assign and return can only occur with at least two statements
    if len(func.body) >= 2:
        return_stmt = func.body[-1]
        if isinstance(return_stmt, ast.Return):
            assign_stmt = func.body[-2]
            if isinstance(assign_stmt, ast.Assign):
                # only assigned to a single variable
                if len(assign_stmt.targets) == 1 and isinstance(
                    assign_stmt.targets[0], ast.Name
                ):
                    if isinstance(return_stmt.value, ast.Name):
                        # check that assigned variable is the same one being returned
                        if return_stmt.value.id == assign_stmt.targets[0].id:
                            return B781(
                                lineno=return_stmt.lineno,
                                col_offset=return_stmt.col_offset,
                            )
    return None


class AssignAndReturnCheck:
    name = "flake8-assign-and-return"
    version = __version__

    def __init__(self, tree: ast.Module) -> None:
        self.tree = tree

    def run(self) -> Iterable[Tuple]:
        visitor = AssignAndReturnVisitor()

        visitor.visit(self.tree)

        yield from visitor.errors


B781 = partial(
    ErrorLoc,
    message="B781: You are assinging to a variable and then returning. Instead remove the assignment and return.",
    type=AssignAndReturnCheck,
)

Then in the setup.py we just need to specify the entry_points for flake8.

setup(
    # --snip--
    keywords="flake8, lint",
    entry_points={
        "flake8.extension": ["B = flake8_assign_and_return:AssignAndReturnCheck"]
    },
    install_requires=["flake8"],
    provides=["flake8_assign_and_return"],
    py_modules=["flake8_assign_and_return"],
)

Conclusion

And that is it. Just python setup.py install and Flake8 will see the plugin.

I’ve put this lint, along with some tests, into a repo. The package is also available on pypi.