Brandon
  • Home
  • Articles
  • Notes

On this page

  • A short history of make
  • Core structure and terms
  • A minimal Python and uv Makefile
  • Naming commands is power
  • Building on changes with timestamps and stamp files
  • Multiple jobs and shared dependencies
  • Functions, conditionals, and variables
    • Assignment operators
    • Conditionals with if and $(if ...)
    • Using functions like shell, wildcard, and subst
    • Multi line variables with define
  • Running shell commands and using $$ correctly
  • Automatic variables and file building
  • One shell per recipe vs many shells
  • Guard rails and phony targets
  • End to end example Makefile
  • Final notes

Make for Python with uv: history, concepts, and a practical cookbook

Automate Python workflows with uv and Make

make
tools
build

Modern projects grow a long list of commands. Install this. Format that. Run tests. Build docs. Deploy. You can keep all of that in your head or in a scratch file, or you can name the commands and let a tool manage the order and the work. That tool is make.

A short history of make

make was created in 1976 by Stuart Feldman at Bell Labs to solve a simple problem: rebuild only what changed. Early C projects compiled many files into programs. Recompiling everything each time was slow. make used file timestamps and a simple dependency graph to do the least work necessary. That idea spread far beyond C. It still works for data workflows, docs, web assets, and Python.

You write rules that say what a target depends on and how to build it. make decides what to run based on what changed. The core is still the same today.

Core structure and terms

The shape of a rule looks like this:

target: prerequisites | order_only_prerequisites
    recipe line 1
    recipe line 2
  • “target”: The thing to build. Often a file. It can also be a named command that is not a file.
  • “prerequisites”: What the target depends on. If any prerequisite is newer than the target, the recipe runs.
  • “order only prerequisites”: Things that must exist first but do not force a rebuild if they change. Put them after a |.
  • “recipe”: The shell commands that build the target. Recipe lines must start with a tab.

Useful terms and variables:

  • “phony target”: A name that does not correspond to a file. Mark these with .PHONY so make always runs them.
  • “automatic variables”: Inside a recipe, $@ is the target name, $< is the first prerequisite, and $^ is the full prerequisite list.
  • “assignment operators”:
    • := expands immediately when defined.
    • = expands later when used.
    • ?= sets a default only if the variable is not already set.
  • “functions”: make has functions like $(shell ...), $(wildcard ...), $(if ...), $(subst ...), and more.

We will use uv for Python throughout. uv is a fast Python package and environment manager. It creates a local virtual environment, installs dependencies, and can run tools without activating the environment. That keeps recipes simple and reliable.

A minimal Python and uv Makefile

Start with a small, readable Makefile that you can grow over time.

.PHONY: help

# Variables
UV ?= uv
VENV ?= .venv
PYTHON := $(VENV)/bin/python
REQS ?= requirements.txt

help:
    @printf "Available commands\n\n"
    @awk 'BEGIN{FS=":.*##"}; /^[a-zA-Z0-9_-]+:.*##/{printf "%-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

.PHONY: venv install

# Create the environment if missing, but do not force rebuilds when it changes
venv: | $(VENV) ## Create local virtual environment

$(VENV):
    $(UV) venv --seed --python=python3 $(VENV)

# Install dependencies using uv into the pinned venv
install: $(VENV) $(REQS) ## Install Python dependencies with uv
    $(UV) pip install -p $(VENV) -r $(REQS)

Run make help to see commands, then make venv, then make install.

This already shows two helpful ideas:

  • Name commands. venv and install are easy to remember and easy to read later.
  • Build on changes. If requirements.txt has not changed and the venv exists, install does nothing.

Naming commands is power

Good names are self documenting. Your future self will thank you when you type make format instead of hunting for a long uv run command.

Examples of helpful names:

  • format: run your formatter on all code
  • lint: run static analysis checks
  • typecheck: run type checker
  • test: run tests
  • quality: run all code quality tasks

Here is how that can look with uv and Python. Notice that we keep everything in the venv without sourcing it.

.PHONY: format lint typecheck test quality

PY_FILES := $(shell find . -type f -name "*.py" -not -path "$(VENV)/*")

format: $(VENV) ## Run Black formatter
    $(UV) pip install -p $(VENV) black
    $(VENV)/bin/black $(PY_FILES)

lint: $(VENV) ## Run Ruff linter
    $(UV) pip install -p $(VENV) ruff
    $(VENV)/bin/ruff check $(PY_FILES)

typecheck: $(VENV) ## Run pyright type checker
    $(UV) pip install -p $(VENV) pyright
    $(VENV)/bin/pyright

test: $(VENV) ## Run pytest test suite
    $(UV) pip install -p $(VENV) pytest
    $(VENV)/bin/pytest -q

# Aggregate target names the work you want
quality: lint format typecheck ## Run all code quality checks

quality is a named story about what happens. It is clear, easy to remember, and it shows what runs.

Building on changes with timestamps and stamp files

make is time based. A target rebuilds when a prerequisite is newer than the target. For phony tasks like formatting, you can create a stamp file to track when the work last completed.

.PHONY: format lint

LINT_STAMP := .lint.stamp
FORMAT_STAMP := .format.stamp

$(LINT_STAMP): $(PY_FILES) | $(VENV)
    $(UV) pip install -p $(VENV) ruff
    $(VENV)/bin/ruff check $(PY_FILES)
    @touch $@

$(FORMAT_STAMP): $(PY_FILES) | $(VENV)
    $(UV) pip install -p $(VENV) black
    $(VENV)/bin/black $(PY_FILES)
    @touch $@

lint: $(LINT_STAMP) ## Lint only if files changed
format: $(FORMAT_STAMP) ## Format only if files changed

If no Python files changed since the stamp files were created, make will skip the work.

Order only prerequisites | $(VENV) ensure the environment exists but do not force reruns when the venv timestamps change.

Multiple jobs and shared dependencies

It is common for several targets to depend on the same setup. Put that setup in one place and reuse it.

.PHONY: build docs package

ARTIFACT_DIR ?= dist

build: $(VENV) ## Build the package into wheel and sdist
    $(UV) pip install -p $(VENV) build
    $(VENV)/bin/python -m build -o $(ARTIFACT_DIR)

docs: $(VENV) ## Build docs with Sphinx
    $(UV) pip install -p $(VENV) sphinx
    $(VENV)/bin/sphinx-build -b html docs/ site/

package: build docs ## Aggregate target for build and docs

You can also run independent prerequisites in parallel when they do not conflict. Use -j to let make schedule them.

make -j 4 package

build and docs will run at the same time if possible, then package completes when both finish.

Functions, conditionals, and variables

Assignment operators

  • := immediate assignment. Expands the right side now.
  • = recursive assignment. Expands when used. The value can change if referenced variables change later.
  • ?= set if not already defined. Good for defaults that users can override with environment variables or command line make VAR=value.
PROJECT_NAME ?= sample-project
SRC_DIR := src
PY_FILES = $(wildcard $(SRC_DIR)/**/*.py)  # expands later
ABS_SRC := $(abspath $(SRC_DIR))           # expands now

Conditionals with if and $(if ...)

Make has a directive style ifeq and a function style $(if ...). Both are useful.

MODE ?= dev

ifeq ($(MODE),prod)
    OPTS := --strict
else
    OPTS := --fast
endif

run: $(VENV)
    $(VENV)/bin/python -m app.main $(OPTS)

Function style works inline:

FLAGS := $(if $(filter $(MODE),prod),--strict,--fast)

Using functions like shell, wildcard, and subst

GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
SRC := $(wildcard src/**/*.py)
TESTS := $(patsubst tests/%.py,%,$(wildcard tests/test_*.py))

print-info:
    @echo Project: $(PROJECT_NAME)
    @echo SHA: $(GIT_SHA)
    @echo Src files: $(words $(SRC))

Multi line variables with define

define PY_HEADER
#!/usr/bin/env python3
import sys
print("hello from a generated file")
endef

generated.py:
    @printf "%s\n" "$(PY_HEADER)" > $@

Running shell commands and using $$ correctly

Inside recipes, the shell sees dollar signs. make also uses $ to expand its own variables. To pass a literal dollar sign to the shell, write $$.

show-path:
    @echo Make variable PATH is: $(PATH)
    @echo Shell expands PATH like this:
    @echo $$PATH

count-py:
    @echo Counting with the shell
    @echo $$(find . -type f -name "*.py" | wc -l)

$(PATH) is a make variable expansion. $$PATH lets the recipe shell expand it at runtime. The same pattern works for $$?, $$@, and other shell variables.

You can also capture a shell result into a make variable outside recipes using $(shell ...) as shown earlier.

Automatic variables and file building

Automatic variables keep recipes short and readable.

DATA_DIR := data
BUILD_DIR := build

$(BUILD_DIR):
    mkdir -p $@

# Pattern rule: transform .txt into .out using Python
$(BUILD_DIR)/%.out: $(DATA_DIR)/%.txt | $(BUILD_DIR) $(VENV)
    @echo Converting $< to $@
    $(VENV)/bin/python - <<'PY'
from pathlib import Path
inp = Path("$<").read_text()
Path("$@").write_text(inp.upper())
print("wrote", "$@")
PY

process: $(BUILD_DIR)/alpha.out $(BUILD_DIR)/beta.out ## Build processed files

$< is the first prerequisite. $@ is the target path. This creates a simple, reproducible text processing pipeline using Python.

One shell per recipe vs many shells

By default each recipe line runs in its own shell. That keeps each line independent. If you need one shared shell, you can opt in with .ONESHELL:.

.ONESHELL:
activate-and-run: $(VENV)
    set -e
    . $(VENV)/bin/activate
    python -V

Most of the time you do not need activation at all because we call tools by absolute path or through uv. Prefer explicit paths. Use .ONESHELL only when it makes the recipe clearer.

Guard rails and phony targets

Declare phony targets so their existence does not depend on files with the same names.

.PHONY: clean distclean

clean: ## Remove build byproducts
    rm -rf $(BUILD_DIR) $(LINT_STAMP) $(FORMAT_STAMP)

distclean: clean ## Remove environment as well
    rm -rf $(VENV)

Use variable guards to catch misconfigurations early.

ifndef PROJECT_NAME
$(error PROJECT_NAME is not defined. Run with make PROJECT_NAME=myproj target)
endif

End to end example Makefile

Here is a cohesive Makefile that brings it all together. It uses uv and Python for every task, names commands clearly, rebuilds only when inputs change, and demonstrates variables, conditionals, functions, automatic variables, and escaping with $$.

.PHONY: help venv install lint format typecheck test quality build docs package process show-path clean distclean

# Configurable defaults
PROJECT_NAME ?= demo
UV ?= uv
VENV ?= .venv
REQS ?= requirements.txt
MODE ?= dev

# Derived variables
PYTHON := $(VENV)/bin/python
PY_FILES := $(shell find . -type f -name "*.py" -not -path "$(VENV)/*")
BUILD_DIR := build
DATA_DIR := data
ARTIFACT_DIR ?= dist

# Stamps for incremental quality tasks
LINT_STAMP := .lint.stamp
FORMAT_STAMP := .format.stamp

# Helpful banner and per target descriptions (##)
help:
    @printf "\n$(PROJECT_NAME) commands\n\n"
    @awk 'BEGIN{FS=":.*##"}; /^[a-zA-Z0-9_-]+:.*##/{printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

# Environment
venv: | $(VENV) ## Create Python venv with uv

$(VENV):
    $(UV) venv --seed --python=python3 $(VENV)

install: $(VENV) $(REQS) ## Install dependencies into venv with uv
    $(UV) pip install -p $(VENV) -r $(REQS)

# Quality
$(LINT_STAMP): $(PY_FILES) | $(VENV)
    $(UV) pip install -p $(VENV) ruff
    $(VENV)/bin/ruff check $(PY_FILES)
    @touch $@

$(FORMAT_STAMP): $(PY_FILES) | $(VENV)
    $(UV) pip install -p $(VENV) black
    $(VENV)/bin/black $(PY_FILES)
    @touch $@

lint: $(LINT_STAMP) ## Lint only changed files since last run
format: $(FORMAT_STAMP) ## Format only changed files since last run

typecheck: $(VENV) ## Run pyright type checker
    $(UV) pip install -p $(VENV) pyright
    $(VENV)/bin/pyright

test: $(VENV) ## Run test suite with pytest
    $(UV) pip install -p $(VENV) pytest
    $(VENV)/bin/pytest -q

quality: lint format typecheck ## Run all quality checks

# Build and docs
build: $(VENV) ## Build the package artifacts
    $(UV) pip install -p $(VENV) build
    $(PYTHON) -m build -o $(ARTIFACT_DIR)

docs: $(VENV) ## Build docs with Sphinx
    $(UV) pip install -p $(VENV) sphinx
    $(VENV)/bin/sphinx-build -b html docs/ site/

package: build docs ## Aggregate build and docs

# Data processing pipeline using automatic vars and Python
$(BUILD_DIR):
    mkdir -p $@

$(BUILD_DIR)/%.out: $(DATA_DIR)/%.txt | $(BUILD_DIR) $(VENV)
    @echo Processing $< -> $@
    $(PYTHON) - <<'PY'
from pathlib import Path
inp = Path("$<").read_text()
Path("$@").write_text(inp.upper())
print("done:", "$@")
PY

process: $(BUILD_DIR)/alpha.out $(BUILD_DIR)/beta.out ## Build processed data files

# Show make vs shell variable expansion and a shell command that uses $$
show-path: ## Demonstrate $$ escaping
    @echo Make PATH is: $(PATH)
    @echo Shell PATH is: $$PATH
    @echo Count py files with the shell: $$(find . -type f -name "*.py" | wc -l)

# Conditional flags example
ifeq ($(MODE),prod)
    FLAGS := --strict
else
    FLAGS := --fast
endif

run: $(VENV) ## Run the app with mode dependent flags
    $(PYTHON) -m app.main $(FLAGS)

# Clean
.PHONY: clean distclean
clean: ## Remove build byproducts
    rm -rf $(BUILD_DIR) $(LINT_STAMP) $(FORMAT_STAMP)

distclean: clean ## Remove environment as well
    rm -rf $(VENV)

You can run several things at once when they do not depend on each other:

make -j 4 quality process

This runs lint, format, and typecheck concurrently, and also builds your processed files, which can speed up feedback on larger projects.

Final notes

  • Name commands generously. It is easier to understand make quality than a long one off command.
  • Let make decide what to rebuild. Attach your targets to the files that matter and use stamp files for phony work.
  • Keep recipes stable by addressing tools through explicit paths or via uv. That avoids fragile activation steps.
  • Prefer small targets that compose well. It keeps your graph clear and your builds fast.

With these patterns you get a Makefile that reads like documentation, runs quickly because it rebuilds only what changed, and uses uv and Python everywhere for a clean developer experience.