A reusable, include-based Makefile that gives any Python project a uv-backed test orchestration layer. Companion to the Python Developer Tooling Handbook.
This README is organized around the Diátaxis documentation framework:
- Tutorial walks through your first setup from scratch.
- How-to guides solve specific tasks.
- Reference lists every variable and target.
- Explanation covers the design decisions.
Start with an empty directory. By the end of this tutorial you'll have a
minimal project that runs tests against two Python versions via make test-all.
$ uv init demo
$ cd demo
$ uv add --dev pytestuv init creates pyproject.toml, src/demo/__init__.py, and a main.py.
uv add --dev pytest records pytest in the dev dependency group.
Write a throwaway test so there's something to run:
def test_ok():
assert 1 + 1 == 2Pull a pinned version into the project root:
$ curl -sSL https://raw.githubusercontent.com/python-developer-tooling-handbook/makefile.uv/v0.4.1/Makefile.uv -o Makefile.uvCreate a Makefile that declares which Python versions you want to test and
includes the file:
PYTHON_VERSIONS := 3.12 3.13
include Makefile.uvAdd the per-version venv directories to .gitignore:
.venv
.venv-*Tip
If your project uses Ruff (it's the
default LINT and FORMAT tool), also add .venv-* to Ruff's
excludes in pyproject.toml — Ruff's default excludes cover
.venv but not .venv-*, so without this make lint will walk
into your per-version venvs:
[tool.ruff]
extend-exclude = [".venv-*"]See Keep ruff out of your per-version venvs for the standalone how-to.
make sync creates the default venv and installs dependencies:
$ make syncmake test runs pytest in that venv:
$ make testmake test-py3.12 runs against Python 3.12 in a dedicated venv at .venv-3.12:
$ make test-py3.12make test-all runs every version in PYTHON_VERSIONS:
$ make test-all$ make cleanThat removes every .venv* directory along with build artifacts and caches.
- Add
FORMAT,LINT, orTYPECHECKvariables and runmake lint/make format/make typecheck. - Read How-to: run tests across a dependency matrix to test your project against two incompatible versions of a dependency.
Declare the competing options as extras with a conflict block in pyproject.toml:
[project.optional-dependencies]
pd1 = ["pandas<2"]
pd2 = ["pandas>=2"]
[tool.uv]
conflicts = [
[{extra = "pd1"}, {extra = "pd2"}],
]Set DEP_VARIANTS alongside PYTHON_VERSIONS:
PYTHON_VERSIONS := 3.11 3.12
DEP_VARIANTS := pd1 pd2
include Makefile.uvRun make matrix. Four cells execute: 3.11 × pd1, 3.11 × pd2, 3.12 × pd1,
3.12 × pd2. Each gets its own venv at .venv-cell-<VER>-<VAR>.
See examples/with-matrix/ for a complete working
project.
When your variants are development-only, reach for
PEP 735 dependency groups. Groups don't
leak into pip install foo[…], so they fit "with feature X vs without" axes
more naturally.
[dependency-groups]
with-chardet = ["chardet"]
without-chardet = []
[tool.uv]
conflicts = [
[{group = "with-chardet"}, {group = "without-chardet"}],
]Set DEP_MODE := group:
DEP_VARIANTS := with-chardet without-chardet
DEP_MODE := group
include Makefile.uvSee examples/with-groups/ for a complete project.
Set TYPECHECK and add ty to your dev group:
$ uv add --dev tyTYPECHECK := ty check
include Makefile.uvmake typecheck now runs uv run ty check.
test-all and matrix stream output to stdout. When you want each
environment's output captured separately, pipe through tee:
$ make test-py3.12 2>&1 | tee py3.12.logFor parallel runs with untangled output, use Make's --output-sync:
$ make -j4 test-all --output-sync=target | tee test-all.logUV_SYNC_FLAGS is empty by default and that's already the right
setting for the common case: uv sync installs the dev dependency
group by default (more precisely, every group in
default-groups,
which defaults to ["dev"]). Setting UV_SYNC_FLAGS := --dev
explicitly is a no-op.
Reach for UV_SYNC_FLAGS only when you want to override that
default — for example, to install without dev dependencies in a
production-image build:
UV_SYNC_FLAGS := --no-dev
include Makefile.uvOther common values: --frozen (refuse to update the lockfile),
--locked (fail if the lockfile is out of date), --extra <name>,
--group <name>.
Set UV_RUN_FLAGS. Common values: --extra cli, --group test, --with ipython.
UV_RUN_FLAGS := --extra cli
include Makefile.uvEvery uv run invocation (including implicit syncs inside test-py<VER>) picks
up the flags.
> choco install makeRun commands from Git Bash (ships with Git for Windows) or WSL. Native
cmd.exe and PowerShell aren't supported because the matrix cell recipe uses
POSIX-shell tools.
Add coverage to your dev dependency group:
$ uv add --dev coverageThen run make coverage for a terminal report or make coverage-html
to also write an HTML report under htmlcov/:
$ make coverage
$ make coverage-html && open htmlcov/index.htmlBoth targets call coverage run -m $(PYTEST) first, so any
[tool.coverage.run] settings in your pyproject.toml
(branch = true, source = [...], etc.) are honored.
Ruff's default excludes cover .venv but not .venv-*. Add an explicit
exclude in pyproject.toml:
[tool.ruff]
extend-exclude = [".venv-*"]Overrides apply when set before include Makefile.uv, via make VAR=…, or in
the environment. Most variables use ?=. LINT is special-cased because GNU
Make has a built-in default LINT = lint; Makefile.uv's default takes effect
when the origin is the built-in, and any user override still wins.
| Variable | Default | Purpose |
|---|---|---|
PYTHON_VERSIONS |
3.11 3.12 3.13 3.14 |
Versions test-all iterates |
DEP_VARIANTS |
(empty) | Variant names for the 2-axis matrix. Empty disables matrix. |
DEP_MODE |
extra |
Whether variants are --extra (PEP 621) or --group (PEP 735) |
PYTEST |
pytest |
Test command |
LINT |
ruff check |
Lint command |
FORMAT |
ruff format |
Format command. Modifies files. Use the format-check target in CI instead of overriding this. |
FORMAT_CHECK |
ruff format --check |
Non-mutating format check, used by the format-check target |
TYPECHECK |
mypy |
Type-check command |
PRE_COMMIT |
uvx pre-commit |
Pre-commit invocation used by pre-commit-install |
DEV_DEPS |
sync pre-commit-install |
Prerequisites for the dev bootstrap target |
CHECK_DEPS |
lint format-check typecheck test |
Prerequisites for the check umbrella target |
COVERAGE |
coverage |
Coverage binary used by the coverage / coverage-html targets |
UV_VENV_PREFIX |
.venv- |
Directory prefix for per-version venvs. Must be non-empty. |
UV_SYNC_FLAGS |
(empty) | Flags forwarded to uv sync. Empty is correct for the common case — uv installs the dev group by default, so UV_SYNC_FLAGS := --dev is redundant. Set this for explicit overrides like --no-dev, --frozen, --extra <name>, or --group <name>. |
UV_RUN_FLAGS |
(empty) | Flags forwarded to every uv run |
| Target | Behavior |
|---|---|
sync |
uv sync $(UV_SYNC_FLAGS) |
dev |
One-shot contributor setup. Depends on $(DEV_DEPS). |
pre-commit-install |
Run $(PRE_COMMIT) install if .pre-commit-config.yaml exists; otherwise a no-op. |
test |
uv run $(PYTEST) in the default venv |
test-py<VER> |
$(PYTEST) on Python <VER> in $(UV_VENV_PREFIX)<VER> |
test-all |
test-py<VER> for each version in PYTHON_VERSIONS |
matrix |
Every Python × DEP_VARIANTS cell |
test-cell-py<VER>-<VAR> |
One matrix cell |
lint |
uv run $(LINT) |
format |
uv run $(FORMAT) (mutates files) |
format-check |
uv run $(FORMAT_CHECK) (non-mutating; for CI) |
typecheck |
uv run $(TYPECHECK) |
check |
lint + format-check + typecheck + test. The "verify before push" / "run in CI" entry point. |
coverage |
$(COVERAGE) run -m $(PYTEST) then $(COVERAGE) report |
coverage-html |
$(COVERAGE) run -m $(PYTEST) then $(COVERAGE) html |
clean |
Removes .venv, $(UV_VENV_PREFIX)*, dist, *.egg-info, .pytest_cache, .coverage, htmlcov |
help |
Prints targets and current variable values |
examples/basic/: minimal project with pytest, ruff, and mypy in the dev group.examples/with-matrix/:DEP_MODE=extrawith two conflicting extras.examples/with-groups/:DEP_MODE=groupwith two conflicting PEP 735 groups.
- GNU Make 3.81 and above. macOS's default
/usr/bin/makesatisfies this. - uv 0.4 and above.
- POSIX shell for the matrix cell recipe. Native
cmd.exeand PowerShell on Windows aren't supported; Git Bash or WSL is required. - Tested in CI on
ubuntu-latest,macos-latest, andwindows-latest.
curl -sSL https://raw.githubusercontent.com/python-developer-tooling-handbook/makefile.uv/v0.4.1/Makefile.uv -o Makefile.uvCommit the pulled file alongside your Makefile. Committing locks the
version; upgrade by re-running the curl with a newer tag.
uv installs Python versions on demand (uv run --python 3.12 downloads
CPython 3.12 if it isn't already available) and manages venvs at the same
speed it downloads wheels. That combination is what makes multi-version
testing practical without tox or nox doing the venv bookkeeping. The rest of
the Python toolchain that Makefile.uv cares about (dependency resolution,
sync, run) uv already covers, so there's nothing left for the Makefile to do
other than expose a few targets.
tox and nox introduce a new vocabulary (envs, factors, sessions) and live
in your project's Python dependency tree. A Makefile already exists on every
developer machine and CI runner, needs no install, and composes freely with
any other target a project wants to add (docs, deploy, db-reset).
Makefile.uv specifically is a single file: pull it with curl, include it,
commit the pinned copy. There's no package to upgrade and nothing to uninstall.
The cost is some Make syntax. The upside is zero runtime footprint.
Make treats a target as up to date when a file of the same name exists and is
newer than its prerequisites. If a user (or a stray shell script) creates a
file named test-py3.12, Make would otherwise think the target has already
been built.
Declaring FORCE: as an empty phony prerequisite forces pattern-matched
targets to rebuild on every invocation, which is what test orchestration
wants.
GNU Make ships a built-in default LINT = lint (for the Unix lint tool).
LINT ?= ruff check sees the built-in as already defined and skips. The
ifeq ($(origin LINT),default) check force-sets Makefile.uv's default only
when the current value came from Make's built-in. A user's override (file,
command line, or environment) bypasses the ifeq and the ?= below it, so
user intent wins.
PEP 621 extras and PEP 735 groups are parallel concepts. A project's matrix
axis is one or the other, not both at once. A single DEP_VARIANTS list plus
a DEP_MODE := extra|group toggle keeps the API small. The cell recipe
translates to uv run --$(DEP_MODE) $$VAR, which accepts both --extra and
--group interchangeably.
tox does more than this Makefile intends to. It runs commands = … across
configured environments, resolves dependencies with its own logic, supports
factors and generative envlists, and extends through a plugin system.
Makefile.uv exposes test-py<VER> targets and leaves composition to Make,
delegates dependency handling to uv, and has exactly one variable (DEP_MODE)
plus one pattern rule for the matrix.
If you need tox's feature set, use tox. Makefile.uv is the 80% solution for projects where uv's multi-version Python support and a POSIX pattern rule are enough.
dev is meant to be the one-command bootstrap a new contributor
runs after cloning. If pre-commit-install errored when
.pre-commit-config.yaml is missing, dev would be unusable for
the (still common) case of projects that don't use pre-commit.
Skipping with a printed note keeps dev uniform across projects
and means a project can opt into pre-commit by committing
.pre-commit-config.yaml without any Makefile change. Projects
that don't want the step at all override DEV_DEPS:
DEV_DEPS := sync
include Makefile.uvcheck runs lint, format-check, typecheck, and test in that
order — cheap-and-noisy first, slow-and-quiet last. Every project
using a standard toolchain wants this same sequence as its
pre-push / CI entry point. Shipping it saves every consumer from
writing the same three-line target.
Projects that need a different sequence override CHECK_DEPS:
CHECK_DEPS := lint typecheck test
include Makefile.uvcheck is defined as check: $(CHECK_DEPS), so any prereq list works
— skip steps, reorder them, or extend with project-local targets
(CHECK_DEPS += docs-test).
ruff format mutates files and ruff format --check doesn't. Local
development wants the mutating version (make format after editing).
CI wants the non-mutating one (fail on drift, don't silently rewrite
committed code). A single FORMAT variable forces a project to pick
one, which means CI overrides break the local workflow and vice versa.
Two targets — format driven by FORMAT, format-check driven by
FORMAT_CHECK — let both run from the same Makefile without an
override.
v0.2 shipped a LOG_DIR variable that tee'd each environment's output to a
per-env log file. The feature required set -o pipefail, which in turn
required upgrading SHELL to /bin/bash, which triggered a cascade of
conditional logic (dash detection, SHELL scoping, split recipes, additional
validation in clean). The whole feature accounted for roughly a third of
the Makefile's complexity.
The replacement is a shell pipe: make test-py3.12 2>&1 | tee py3.12.log.
For parallel runs, Make's own --output-sync=target untangles streams
without any Makefile.uv machinery. The savings in clarity outweighed the
small convenience of a built-in log directory.
MIT. See LICENSE.
