Opinionated Makefiles

At Equium we extract out common language-level operations into //tools/make that we then include into smaller Package-level Makefiles

Example Makefile

Each package’s Makefile is mostly just include statements and setting any variables that need to be overriden.

//services/backend/Makefile.common:

#  -*- mode: makefile; -*-
include ../../tools/make/Makefile.common
include ../../tools/make/Makefile.python

IMAGE_NAME = myproject/backend
include ../../tools/make/Makefile.docker
include ../../tools/make/Makefile.mixins-post

//tools/make/Makefile.common

  • Common configuration from Davis Hansson’s “Your Makefiles are wrong”, notably switching to use > as the recipe prefix.

  • Sets up several variables that are used elsewhere by rules and the targets

  • Provides a make-lazy function to convert expensive shell computations to only be run when requested

#  -*- mode: makefile; -*-

SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
MAKEFLAGS += --no-print-directory

ifeq ($(origin .RECIPEPREFIX), undefined)
$(error This Make does not support .RECIPEPREFIX. Please use GNU Make 4.0 or later)
endif
.RECIPEPREFIX = >

SELF_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
WORKSPACE_DIR := $(abspath $(SELF_DIR)/../../..)
WORKSPACE_BIN := $(abspath $(WORKSPACE_DIR)/bin)
PKG_NAME := $(shell basename $(CURDIR))

GIT_SHA = $(shell git log -1 --format=format:%H)

MAZEL := $(WORKSPACE_BIN)/mazel

# TODO Consider a clean-sentinel to rm build/*.sentinel
clean-sentinels:
> rm -f build/*.sentinel
.PHONY: clean-sentinels


# make a recursive variable lazy (compute once upon usage)
# Usage:
#   EXPENSIVE = $(shell run)
#   $(call make-lazy,EXPENSIVE)
# From jgc: https://blog.jgc.org/2016/07/lazy-gnu-make-variables.html
make-lazy = $(eval $1 = $$(eval $1 := $(value $(1)))$$($1))

//tools/make/Makefile.python

#  -*- mode: makefile; -*-

# Optional Configuration:
#
# - BUILD_PREREQS
#     Extra depedencies for make build
# - POETRY_EXTRAS
#     list of packages to be included in `poetry install --extras ...`
# - TEST_PREREQS
#     Extra depedencies for make test
# - SOURCES
#     .py files that are built into the whl, typically defined via shell find,
#     defaults to looking in project/ directory


# Make it easier to adjust which poetry to use during upgrade testing
POETRY := poetry

PACKAGE_NAME := $(shell grep "^name" pyproject.toml | cut -f 2 -d '=' | tr -d '" ')
PACKAGE_VERSION := $(shell grep "^version" pyproject.toml | cut -f 2 -d '=' | tr -d '" ')
# WARN: assumes the wheel is universal
PACKAGE_WHL := $(PACKAGE_NAME)-$(PACKAGE_VERSION)-py3-none-any.whl

TEST_PREREQS ?=

poetry.lock: pyproject.toml
# If it is a new package, help by generating a lock file,
# but otherwise don't re-run poetry lock automatically as it will upgrade
# packages
> test -f  poetry.lock || $(POETRY) lock
# In case poetry.lock existed before pyproject.toml, update the target
> touch -c poetry.lock

POETRY_INSTALL_OPTIONS ?=
ifdef POETRY_EXTRAS
POETRY_INSTALL_OPTIONS += --extras "$(POETRY_EXTRAS)"
endif

# TODO include relative dependencies
.venv: poetry.lock poetry.toml
> $(POETRY) install $(POETRY_INSTALL_OPTIONS)
# In case .venv existed before poetry.lock, update the target
> touch -c .venv

INIT_TARGETS += .venv

ifndef SOURCES
SOURCES := $(shell find project -name "*.py")
endif
BUILD_PREREQS += $(SOURCES)
BUILD_PREREQS ?=
BUILD_OUT := dist/$(PACKAGE_WHL)
$(BUILD_OUT): .venv $(BUILD_PREREQS)
> $(POETRY) build -f wheel

build: $(BUILD_OUT)
.PHONY: build

TEST_ARGS = -m unittest discover -t . -s tests
TESTS_EXIST := $(shell test -d tests && echo 1 || echo 0)
ifeq ($(TESTS_EXIST), 1)
test-py: .venv $(TEST_PREREQS)
> $(POETRY) run python $(TEST_ARGS)
.PHONY: test-py
TEST_TARGETS += test-py
endif

CHECK_ONLY ?=
ifdef CHECK_ONLY
ISORT_ARGS=--check-only -q
BLACK_ARGS=--check -q
else
ISORT_ARGS=
BLACK_ARGS=
endif
format:
# isort will look up parent directories until it finds a .isort.cfg (in workspace root)
# TODO only format tests/ and `package.name` (extract from pyproject.toml)
> $(WORKSPACE_BIN)/isort --virtual-env .venv $(ISORT_ARGS) .
> $(WORKSPACE_BIN)/black $(BLACK_ARGS) .
.PHONY: format

lint-py:
> $(WORKSPACE_BIN)/flake8 .
> CHECK_ONLY=true $(MAKE) format
.PHONY: lint-py
LINT_TARGETS += lint-py

mypy: .venv
> $(WORKSPACE_BIN)/mypy \
>   --config-file=$(WORKSPACE_DIR)/mypy.ini \
>   --python-executable=.venv/bin/python \
>   --namespace-packages -p $(PACKAGE_NAME)
.PHONY: mypy

_clean_poetry:
> rm -rf .venv
.PHONY: _clean_poetry

_clean_build:
> rm -rf build dist *.egg-info
.PHONY: _clean_build

CLEAN_TARGETS += _clean_poetry _clean_build

clean-py:
> rm -rf *.egg-info pip-wheel-metadata .mypy_cache .coverage
> find . -name "*.pyc" -type f -delete
> find . -type d -empty -delete
.PHONY: clean-py
CLEAN_TARGETS += clean-py clean-sentinels

TODO Makefile.node

//tools/make/Makefile.docker

#  -*- mode: makefile; -*-

# Required Configuration:
#
# - IMAGE_NAME
#     Name of the docker image (used in tag)
# - IMAGE_TAG
#     Image tag. Defaults "latest"
#
#
# Optional Configuration:
# - IMAGE_BUILD_PATH
#      Docker build context root. Default "."
# - IMAGE_PREREQS
#     In addition to the Dockerfile, other Makefile prerequisites for image build
# - GOSS_SLEEP
#     seconds to wait before running tests, in case process needs to start (default 0.2)
# - TEST_IMAGE_ARGS
#     docker arguments to pass into dgoss
# - TEST_IMAGE_PREREQS
#     Additional Makefile prerequisites for test-image
# - TEST_IMAGE_RUN_ARGS
#     docker run arguments (e.g. after the IMAGE name)

IMAGE_TAG ?= latest
IMAGE_BUILD_PATH ?= .
IMAGE_PREREQS ?=
GOSS_SLEEP ?= 0.2  # default
TEST_IMAGE_ARGS ?=
TEST_IMAGE_PREREQS ?=
TEST_IMAGE_RUN_ARGS ?=

image: Dockerfile $(IMAGE_PREREQS)
> docker build -t $(IMAGE_NAME):$(IMAGE_TAG) \
>  --build-arg GIT_SHA=$(GIT_SHA) \
>  -f ${CURDIR}/Dockerfile $(IMAGE_BUILD_PATH)
.PHONY: image

# use --platform until we build for arm
test-image: image $(TEST_IMAGE_PREREQS)
> GOSS_SLEEP=$(GOSS_SLEEP) GOSS_OPTS="--format rspecish" \
>  $(WORKSPACE_DIR)/bin/dgoss \
>  run --platform linux/amd64 $(TEST_IMAGE_ARGS) $(IMAGE_NAME):$(IMAGE_TAG) $(TEST_IMAGE_RUN_ARGS)
.PHONY: test-image

clean-image: clean-sentinels
# TODO implement (sentinel, image?)
.PHONY: clean-image
CLEAN_TARGETS += clean-image

//tools/make/Makefile.mixins-post

Since we use Consistent Targets, we often have one package that needs to run multiple rules for the same target, e.g. clean could run clean-py and clean-docker, our solution is a “mixin” that allows appending targets to a list, that are then all run.

#  -*- mode: makefile; -*-
# Allows multiple Makefiles.* mixins to contribute to common targets (clean, test, etc),
# since a concrete Makefile may implement multiple mixins.
# Since mazel uses `make -n` to dry-run to see if the target exists, we need to wrap
# the targets in `ifdef`
#
# Must be include'ed last and after any added _TARGETS
#
# Optional Targets:
#  - CLEAN_TARGETS: for `make clean`, should use as CLEAN_TARGETS += clean-my-target
#  - INIT_TARGETS: for `make init`, should use as INIT_TARGETS += init-my-target
#  - LINT_TARGETS: for `make lint`, should use as LINT_TARGETS += lint-my-target
#  - TEST_TARGETS: for `make test`, should use as TEST_TARGETS += test-my-target


ifdef CLEAN_TARGETS
clean:
> $(foreach var, $(CLEAN_TARGETS), $(MAKE) $(var);)
.PHONY: clean
endif


ifdef INIT_TARGETS
init:
> $(foreach var, $(INIT_TARGETS), $(MAKE) $(var);)
.PHONY: init
endif


ifdef LINT_TARGETS
lint:
> $(foreach var, $(LINT_TARGETS), $(MAKE) $(var);)
.PHONY: lint
endif


ifdef TEST_TARGETS
test:
> $(foreach var, $(TEST_TARGETS), $(MAKE) $(var);)
.PHONY: test
endif