still don't have a title

Writing Makefiles for Python Projects

I’m a big fan of Makefiles. Almost all my side projects are using them, and I’ve been advocating their usage at work too.

Makefiles give your contributors an entry point on how to do certain things like, building, testing, deploying. And if done correctly, they can massively simplify your CI/CD pipeline scripts as they can often just stupidly call the respective make targets. Most importantly, they are a very convenient shortcut for you as a developer as well.

For Python projects, where I’m almost always using virtual environments, I’ve been using two different strategies for Makefiles:

  1. assuming that make is executed inside the virtual environment
  2. wrapping all virtual environment calls inside make

Both strategies have their pros and cons.

Assuming make is executed inside the venv

Let’s have a look at a very simple Makefile that allows for building, testing and releasing a Python project:

all: lint test

.PHONY: test

.PHONY: lint

.PHONY: release
    python3 sdist bdist_wheel upload

    find . -type f -name *.pyc -delete
    find . -type d -name __pycache__ -delete

This is straightforward and a potential contributor immediately knows the entry points to your project.

Assuming there is a venv installed already, you have to activate it first and run the make commands afterwards:

$ . venv/bin/activate
$ make test

The downside is of course, that you have to activate the venv for every new shell. Which can get a bit annoying when you spawn a new terminal in tmux or put vim into the background to run make.

Activating the venv inside make will not work, as each recipe runs in its own shell, moreover each command in each recipe runs in its own shell too. There are workarounds for the latter, i.e. using the .ONESHELL flag, but that does not solve the issue of a new shell in each recipe.

Wrapping the venv calls inside make

The second approach mitigates for the issue of activating the venv altogether. I’ve borrowed this idea mostly from makefile.venv and simplified it a lot for my needs.

# system python interpreter. used only to create virtual environment
PY = python3
VENV = venv

# make it work on windows too
ifeq ($(OS), Windows_NT)

all: lint test

$(VENV): requirements.txt requirements-dev.txt
    $(PY) -m venv $(VENV)
    $(BIN)/pip install --upgrade -r requirements.txt
    $(BIN)/pip install --upgrade -r requirements-dev.txt
    $(BIN)/pip install -e .
    touch $(VENV)

.PHONY: test
test: $(VENV)

.PHONY: lint
lint: $(VENV)

.PHONY: release
release: $(VENV)
    $(BIN)/python sdist bdist_wheel upload

    rm -rf $(VENV)
    find . -type f -name *.pyc -delete
    find . -type d -name __pycache__ -delete

The equivalent Makefile now looks immediately more complicated. So let’s break it down.

Instead of calling just calling pytest, flake8 and python assuming the venv is already activated or all dependencies are installed on the system directly, we explicitly call the ones from the venv by prefixing the command with the path to the venv’s bin directory. To ensure the venv exists, each of the recipes is depending on the $(VENV) target, which ensures we always have an up-to-date venv installed.

This works, because the . venv/bin/activate-script basically just does the same: it puts the venv before anything else in your PATH, therefore each call to python, etc. will find the one installed in the venv first.

While the Makefile is a bit more complicated, we now can just call

$ make test

and don’t deal with venvs directly any more (well, for those simple cases at least…). If you don’t need to support Windows, you can remove the appropriate block and the Makefile looks relatively tame, even for people that don’t use make very often.

Which one is better?

I think the second approach is more convenient. I’ve used the first approach happily for years and only learned quite recently about the second one. I haven’t really noticed any downsides yet, but I do realize that almost all Python projects with Makefiles I’ve checked out, seem to prefer the first approach. I wonder why that is?