Makefile tricks for Python projects
Table of contents
I like using Makefiles. They work great both as simple task runners as well as build systems for medium-size projects. This is my starter template for Python projects.
Note: This blog post assumes some basic knowledge of how make
and Makefiles work.
Basic configuration
I like using bash as the default shell. Then set some flags to exit on error (-eu -o pipefail
), warn about undefined variables and disable built-in rules.
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
Using the virtual environment
The next lines will create two aliases to run Python from an existing virtual environment if it exists
py = $$(if [ -d $(PWD)/'.venv' ]; then echo $(PWD)/".venv/bin/python3"; else echo "python3"; fi)
pip = $(py) -m pip
Now, inside any recipe, you can use $(py)
to call Python. The call will be converted to .venv/bin/python3
if there’s a virtual environment. You may need to change $(PWD)
and/or .venv
depending on how you set up your virtual environments.
$(pip)
will also translate to .venv/bin/python3 -m pip
if there’s a virtual environment. Otherwise, it will translate to python3 -m pip
.
The reason to use $$(some-command)
instead of the built-in function $(shell some-command)
is that the expression will be evaluated every time it is called. So, every time a recipe contains $(py)
, the call will be translated to python3
or .venv/bin/python3
depending on the current context. When using $(shell ...)
the expression gets evaluated only once and reused across all the recipes.
PWD and the repo root
NOTE: You don’t need to change $(PWD)
, see “update” below.
I normally prefer overriding PWD
. You can call makefiles from a different directory, which will change PWD
. These two lines will set:
PWD
: Location of the MakefileWORKTREE_ROOT
: The root of the git repo. If this Makefile is used outside of a worktree, the variable will be an empty string.
# Override PWD so that it's always based on the location of the file and **NOT**
# based on where the shell is when calling `make`. This is useful if `make`
# is called like `make -C <some path>`
PWD := $(realpath $(dir $(abspath $(firstword $(MAKEFILE_LIST)))))
WORKTREE_ROOT := $(shell git rev-parse --show-toplevel 2> /dev/null)
Update
I kind of forgot about the CURDIR
variable. Thanks to @phoebos on lobsters for the tip. It’s very likely you want to use $(CURDIR)
, not $(PWD)
1. Even if this feature is specific to GNU Make, I’m mostly worried about the Makefile working in the (old) make
version shipped by macOS (3.81 in my case), and it seems to work there too. That variable was initially added in 19982.
MAKEFILE_PWD := $(CURDIR)
WORKTREE_ROOT := $(shell git rev-parse --show-toplevel 2> /dev/null)
Default goal and help message
Just a recipe to run some regex over the makefile and print a help message
.DEFAULT_GOAL := help
.PHONY: help
help: ## Display this help section
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z\$$/]+.*:.*?##\s/ {printf "\033[36m%-38s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
By setting .DEFAULT_GOAL := help
, the help
recipe will run when calling make
without a target.
Now you can add a string to the recipes like this:
some_target: ## This help message will get printed
@echo hello
@touch some_target
Injecting paths into PYTHONPATH
This is probably my favourite trick. Once you have all your third-party dependencies installed, instead of installing all your packages, you can append some file system paths to the PYTHONPATH
environment variable. This will let Python search for packages in those paths without installing the packages.
# PY_PATHS := list with paths to packages
pypath := python3 -c 'import sys, pathlib as p; print(":".join([str(p.Path(x).resolve()) for x in sys.argv[1:]]))'
How does it work?
First, you need a list of paths to python packages. For example:
PY_PATHS := $(PWD)/pkgs/package1 $(PWD)/pkgs/package2
The pypath
variable is a compressed version of this Python script:
import sys
from pathlib import Path
paths = [Path(x).resolve() for x in sys.argv[1:]]
paths = [str(x) for x in paths]
joined = ":".join(paths)
print(joined)
Using it
PY_PATHS := $(PWD)/pkgs/package1 $(PWD)/pkgs/package2
pypath := python3 -c 'import sys, pathlib as p; print(":".join([str(p.Path(x).resolve()) for x in sys.argv[1:]]))'
.PHONY: test-pypath
test-pypath: export PYTHONPATH = $(shell $(pypath) $(PY_PATHS))
test-pypath:
@python3 -c 'import sys; print(sys.path)'
When you run this makefile, you’ll get an output like this:
['',
'<YOUR PWD>/pkgs/package1',
'<YOUR PWD>/pkgs/package2',
'/opt/homebrew/Cellar/[email protected]/3.11.3/Frameworks/Python.framework/Versions/3.11/lib/python311.zip',
'/opt/homebrew/Cellar/[email protected]/3.11.3/Frameworks/Python.framework/Versions/3.11/lib/python3.11',
'/opt/homebrew/Cellar/[email protected]/3.11.3/Frameworks/Python.framework/Versions/3.11/lib/python3.11/lib-dynload',
'/opt/homebrew/lib/python3.11/site-packages']
Now, if the paths '<YOUR PWD>/pkgs/package1'
and '<YOUR PWD>/pkgs/package2'
contain a Python package, the interpreter will be able to import them without having to install them first. Just make sure all their dependencies are installed.
Why this is useful
This may be a bit unrelated to this blog post. I work using git worktrees to have multiple branches checked out at the same time. By using this trick, I can run different commands and make sure the imported package is from that branch, instead of having to python3 -m pip install -e .
around all the packages.
I also use this to run scripts which use packages that have heavy dependencies. I can still the third-party dependencies only once and then point to different paths (branches) to run the same command. Each run will use the code from that branch, without needing to reinstall the different versions of the package in different virtual environments.
Adding more paths when calling make
If you want to allow passing extra paths when calling make
, you can store the initial value in a variable and append it to the exported value.
PY_PATHS := $(PWD)/pkgs/package1 $(PWD)/pkgs/package2
pypath := python3 -c 'import sys, pathlib as p; print(":".join([str(p.Path(x).resolve()) for x in sys.argv[1:]]))'
# Store the current value
DEFAULT_PYPATH := $(shell echo $$PYTHONPATH)
.PHONY: test-pypath
test-pypath: export PYTHONPATH = $(shell $(pypath) $(PY_PATHS)):$(DEFAULT_PYPATH)
test-pypath:
@python3 -c 'import sys, pprint; pprint.pprint(sys.path)'
Now you can do this:
PYTHONPATH=./some/extra/path make test-pypath
And PYTHONPATH
will contain ./some/extra/path
, $(PWD)/pkgs/package1
and $(PWD)/pkgs/package2
.
Creating a virtual environment
This rule can change depending on what tools you use to manage your virtual environments. But I use something like:
.venv: requirements.txt
$(py) -m venv .venv
$(pip) install -U pip setuptools wheel build
$(pip) install -U -r requirements.txt
touch .venv
This recipe will create a new environment in a .venv
folder. It will then update and/or install pip
, setuptools
, wheel
and build
. It will install the requirements in requirements.txt
. The last command touch .venv
ensures that the modified date of the .venv
folder is more recent than requirements.txt
, which will avoid accidental rebuilds.
The template
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
# .DELETE_ON_ERROR:
MAKEFLAGS = --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
# Override PWD so that it's always based on the location of the file and **NOT**
# based on where the shell is when calling `make`. This is useful if `make`
# is called like `make -C <some path>`
PWD := $(realpath $(dir $(abspath $(firstword $(MAKEFILE_LIST)))))
WORKTREE_ROOT := $(shell git rev-parse --show-toplevel 2> /dev/null)
# Using $$() instead of $(shell) to run evaluation only when it's accessed
# https://unix.stackexchange.com/a/687206
py = $$(if [ -d $(PWD)/'.venv' ]; then echo $(PWD)/".venv/bin/python3"; else echo "python3"; fi)
pip = $(py) -m pip
.DEFAULT_GOAL := help
.PHONY: help
help: ## Display this help section
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z\$$/]+.*:.*?##\s/ {printf "\033[36m%-38s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.venv: requirements.txt ## Build the virtual environment
$(py) -m venv .venv
$(pip) install -U pip setuptools wheel build
$(pip) install -U -r requirements.txt
touch .venv
Everything else
Now, depending on your tooling, you may want to add recipes to call pip-tools
, poetry
, black
, ruff
, etc. I didn’t include those because I think it depends on how each project is set up.
If you’re looking for a general make
tutorial, I think this is a good place to start. The GNU Make manual is also a good place to go deeper into some topics.