Tooling configuration for brand new Python projects
Quickstart
- Copy over the
Makefileto your Python project. - Run
make helpto see what you can do.
[ A short thread on what using Metapy looks like: <link> (<date>) ]
Table of Contents
- Quickstart
- Introduction
- The PHONY section of the Makefile
- The common variables section of the Makefile
- Set Bash as the shell for executing commands
- The default goal section of the Makefile
- The help target to explain what you can do with the Makefile
- The Environment Setup section
- The Development Tools Installation section
- The Code Quality section
- The Testing section
- The Build section
- The Infrastructure section
- The Deployment section
- The Maintenance section
Introduction
Whenever I start a new Python project, I run through a series of steps to standardize the project’s developer experience. This project is an attempt to short-circuit that, and also to note all the steps down for my own reference in the future.
To make a change to Metapy’s Makefile, edit this file and then
type C-c C-v C-t (M-x org-babel-tangle) to publish the changes.
The PHONY section of the Makefile
A target in a Makefile is meant to be an actual file that the
“execution” of the target creates. Sometimes, we want “actions” as
target – think make check or make build, we do not expect a file
called check or a file called build to be created. We call these
targets as phony targets, and informing make helps it avoid checking
for the existence of these files.
Our PHONY targets are as follows:
- Environment Setup (You only need to do this once)
venv(Creating virtual environment and .env file. See: The Virtual Environment section of the Makefile)install-dev-tools(Installing all development tools. See: The Development Tools Installation section)
- Code Quality
check(Running all quality checks. See: The Checking section of the Makefile)format(Formatting code with ruff. See: The Formatting section of the Makefile)
- Testing our Python project
test(Running unit tests. See: The Testing section)test-llm(Running LLM tests)test-integration(Running integration tests)
- Building our Python project
build(Building deployment artifact. See: The UV Build section of the Makefile)docker-build(Building Docker image. See: The Docker Build section of the Makefile)docker-compose-build(Building local infrastructure)
- Running Infrastructure
up(Starting local infrastructure. See: The Docker Compose section of the Makefile)down(Stopping local infrastructure)migrate(Running database migrations. See: The Database Migration section of the Makefile)server(Running FastAPI server. See: The FastAPI Server section of the Makefile)
- Deploying our Python project to Production
deploy(Deploying to production with backup. See: The Fly.io Deployment section of the Makefile)rollback(Rolling back to previous version. See: The Rollback section of the Makefile)prepare(Building and uploading image. See: The Build-only Deployment section of the Makefile)
- Maintenance
upgrade-libs(Upgrading dependencies. See: The Dependency Upgrade section of the Makefile)clean(Cleaning artifacts. See: The Cleaning section of the Makefile)
PHONY targets are declared right above the target itself, for easy maintainability and readability
The common variables section of the Makefile
These are variables we use in some of the other Makefile targets.
You can ignore them for now and we’ll review them when we use them.
HOME := $(shell echo $$HOME)
HERE := $(shell echo $$PWD)
Set Bash as the shell for executing commands
# Set bash instead of sh for the @if [[ conditions,
# and use the usual safety flags:
SHELL = /bin/bash -Eeu
The default goal section of the Makefile
The .DEFAULT_GOAL is what runs when we only run the make command
with no directive. In our case, we want this to print the help options
and exit, so that the user has a clear idea of what is possible with
this Makefile.
.DEFAULT_GOAL := help
The help target to explain what you can do with the Makefile
Here, we use awk to filter out all the targets which have a
doc-string. This is the “public API” of the Makefile, so to speak.
/^[a-zA-Z0-9_-]+:.*##/ is a pattern that matches lines starting with
a target name (letters, numbers, underscores, or hyphens) followed by
a colon, followed by ## somewhere in the line. This finds Makefile
target definitions.
In the print command:
%-25sformats the target name left-aligned in 25 characterssubstr($$1, 1, length($$1)-1)takes the target name (first field) without the trailing colonsubstr($$0, index($$0,"##")+3)extracts everything after ## (the comment)
.PHONY: help
help: ## A brief listing of all available commands
@awk '/^[a-zA-Z0-9_-]+:.*##/ { \
printf "%-25s # %s\n", \
substr($$1, 1, length($$1)-1), \
substr($$0, index($$0,"##")+3) \
}' $(MAKEFILE_LIST)
The Environment Setup section
In this section, we set up the basic environment needed for a Python project. This includes creating the necessary configuration files and directory structure.
The .env and .env.sample section of the Makefile
Environment variables are crucial for Python applications. We create a
.env.sample file as a template, and then copy it to .env if it
doesn’t exist. This follows 12-factor app principles.
.env.sample:
touch .env.sample
.env: .env.sample ## Copy .env.sample to .env if .env doesn't exist
@if [ ! -f .env ]; then \
echo "Creating .env from .env.sample..."; \
cp .env.sample .env; \
echo "✓ .env created. Please edit it with your actual values."; \
else \
echo ".env already exists, skipping..."; \
fi
The pyproject.toml section of the Makefile
The pyproject.toml file is the modern standard for Python project
configuration. We create a basic template using hatchling as the build
backend, which is simple and reliable.
pyproject.toml:
@if [ ! -f pyproject.toml ]; then \
echo "Creating pyproject.toml..."; \
echo '[build-system]' > pyproject.toml; \
echo 'requires = ["hatchling"]' >> pyproject.toml; \
echo 'build-backend = "hatchling.build"' >> pyproject.toml; \
echo '' >> pyproject.toml; \
echo '[project]' >> pyproject.toml; \
echo 'name = "metapy"' >> pyproject.toml; \
echo 'version = "0.1.0"' >> pyproject.toml; \
echo 'description = ""' >> pyproject.toml; \
echo 'authors = []' >> pyproject.toml; \
echo 'keywords = []' >> pyproject.toml; \
echo 'packages = [{include="src"}]' >> pyproject.toml; \
echo 'requires-python = ">=3.14.0"' >> pyproject.toml; \
echo '' >> pyproject.toml; \
fi
The Virtual Environment section of the Makefile
We use UV for fast and reliable Python package management. The venv
target creates the virtual environment, sets up the basic directory
structure, and installs dependencies.
tests:
mkdir tests
src:
mkdir -p src/metapy && touch src/metapy/__init__.py
.venv: pyproject.toml tests src
uv venv
uv lock
uv sync --locked --no-cache
.PHONY: venv
venv: .venv .env ## Create the .venv and the .env files
@echo "Virtual environment created at .venv/"
The Development Tools Installation section
This section installs all the development tools needed for a modern Python project. Each tool is installed separately so you can pick and choose what you need.
The Ruff section of the Makefile
ruff is an extremely fast Python linter and formatter, written in
Rust. It combines the functionality of many separate tools into one
fast package.
.PHONY: install-ruff
install-ruff: pyproject.toml
uv add ruff --group dev
@if ! grep -q "\[tool.ruff.lint\]" pyproject.toml; then \
echo '' >> pyproject.toml; \
echo '[tool.ruff.lint]' >> pyproject.toml; \
echo 'select = [' >> pyproject.toml; \
echo ' # pycodestyle' >> pyproject.toml; \
echo ' "E",' >> pyproject.toml; \
echo ' # Pyflakes' >> pyproject.toml; \
echo ' "F",' >> pyproject.toml; \
echo ' # pyupgrade' >> pyproject.toml; \
echo ' "UP",' >> pyproject.toml; \
echo ' # flake8-bugbear' >> pyproject.toml; \
echo ' "B",' >> pyproject.toml; \
echo ' # flake8-simplify' >> pyproject.toml; \
echo ' "SIM",' >> pyproject.toml; \
echo ' # isort' >> pyproject.toml; \
echo ' "I",' >> pyproject.toml; \
echo ']' >> pyproject.toml; \
echo 'ignore = ["E501"]' >> pyproject.toml; \
fi
The Pytest section of the Makefile
pytest is the de facto standard testing framework for Python. We
configure it with useful defaults for async testing and logging.
.PHONY: install-pytest
install-pytest: pyproject.toml
uv add pytest pytest-asyncio --group dev
@if ! grep -q "\[tool.pytest.ini_options\]" pyproject.toml; then \
echo '' >> pyproject.toml; \
echo '[tool.pytest.ini_options]' >> pyproject.toml; \
echo 'testpaths = ["tests"]' >> pyproject.toml; \
echo 'addopts = "-v --tb=short"' >> pyproject.toml; \
echo 'asyncio_mode = "auto"' >> pyproject.toml; \
echo 'log_cli = true' >> pyproject.toml; \
echo 'log_cli_level = "INFO"' >> pyproject.toml; \
echo 'log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"' >> pyproject.toml; \
echo 'asyncio_default_fixture_loop_scope = "function"' >> pyproject.toml; \
fi
The Ty section of the Makefile
ty is a simple type checker that complements more complex type
checkers like basedpyright.
.PHONY: install-ty
install-ty:
uv add ty --group dev
The BasedPyright section of the Makefile
basedpyright is a fork of Pyright with more sensible defaults and
better performance for large codebases.
.PHONY: install-basedpyright
install-basedpyright: pyproject.toml
uv add basedpyright --group dev
@if ! grep -q "\[tool.basedpyright\]" pyproject.toml; then \
echo '' >> pyproject.toml; \
echo '[tool.basedpyright]' >> pyproject.toml; \
echo 'reportAny = false' >> pyproject.toml; \
echo 'reportExplicitAny = false' >> pyproject.toml; \
echo 'reportUnknownMemberType = false' >> pyproject.toml; \
echo 'reportUnknownArgumentType = false' >> pyproject.toml; \
echo 'reportUnknownVariableType = false' >> pyproject.toml; \
echo 'reportUnknownLambdaType = false' >> pyproject.toml; \
fi
The Tagref section of the Makefile
tagref is a tool for checking that references to code (like
[tag:example]) actually exist in the codebase.
.PHONY: install-tagref
install-tagref:
@if ! command -v tagref >/dev/null 2>&1; then \
echo "tagref executable not found. Please install it from https://github.com/stepchowfun/tagref?tab=readme-ov-file#installation-instructions"; \
exit 1; \
fi
The Bandit section of the Makefile
bandit is a security linter designed to find common security issues
in Python code.
.PHONY: install-bandit
install-bandit:
uv tool install bandit
The Git Hooks section of the Makefile
Git hooks ensure code quality before commits by running checks automatically. The hook is embedded directly in the Makefile using a heredoc, so no external files are needed.
.git/hooks/pre-push:
@echo "Setting up Git hooks..."
@cat > .git/hooks/pre-push << 'EOF'
#!/bin/sh
# Redirect output to stderr for visibility
exec 1>&2
echo "Running pre-push checks..."
echo "Running make check (lints/static analysis)"
if ! make check; then
echo "❌ make check failed. Aborting push."
echo " Fix the issues and try again."
exit 1
fi
echo "Running make format (auto-formatting)"
if ! make format; then
echo "❌ make format failed. Aborting push."
exit 1
fi
echo "Checking for uncommitted changes after formatting"
if ! git diff --exit-code --quiet; then
echo "⚠️ Code was reformatted by 'make format'!"
echo " Please commit these changes before pushing."
exit 1
fi
echo "Running make test (unit tests)"
if ! make test; then
echo "❌ make test failed. Aborting push."
echo " Fix the test failures and try again."
exit 1
fi
echo "✅ All checks passed. Pushing changes..."
exit 0
EOF
@chmod +x .git/hooks/pre-push
@echo "✅ Git hooks installed successfully!"
.PHONY: install-hooks
install-hooks: .git/hooks/pre-push
The Configuration Files section of the Makefile
These configuration files are needed for various tools to work properly with the project.
AGENTS.md:
@echo "Download the CONVENTIONS.md file from the [[https://github.com/unravel-team/metapy][metapy]] project, then symlink it to AGENTS.md and CLAUDE.md"
.aider.conf.yml:
@echo "Download the .aider.conf.yml file from the [[https://github.com/unravel-team/metapy][metapy]] project"
.gitignore:
@echo "Download the .gitignore file from the [[https://github.com/unravel-team/metapy][metapy]] project"
.PHONY: install-dev-tools
install-dev-tools: install-ruff install-pytest install-ty install-basedpyright install-tagref install-bandit install-hooks AGENTS.md .aider.conf.yml .gitignore ## Install all development tools (Ruff, Pytest, Ty, Tagref, Bandit, Hooks)
The Code Quality section
This section contains all the targets for checking and formatting code to ensure it meets our quality standards.
The Checking section of the Makefile
This section runs multiple checks in order from fastest to slowest:
- UV lock check (verifies dependency consistency)
- Ruff linting (fast Python linting)
- Tagref (checks code references)
- Ty type checking (simple type checker)
- Bandit security analysis
- BasedPyright type checking (comprehensive type checking)
Run the command make check to see if your code is compliant.
.PHONY: check-bandit
check-bandit:
bandit -q -ii -lll -c .bandit.yml -r src/
.PHONY: check-tagref
check-tagref: install-tagref
tagref
.PHONY: check-uv
check-uv:
uv lock --check
.PHONY: check-ruff
check-ruff:
uv run ruff check -n src tests
.PHONY: check-ty
check-ty:
uv run ty check src tests
.PHONY: check-basedpyright
check-basedpyright:
uv run basedpyright src tests
.PHONY: check
check: check-uv check-ruff check-tagref check-ty check-bandit check-basedpyright ## Check that the code is well linted, well typed, well documented. Fast checks first, slow later
@echo "All checks passed!"
The Formatting section of the Makefile
ruff is used for both linting and formatting. The format target
first fixes any auto-fixable linting issues, then formats the code.
Run the command make format to format all your Python source files.
.PHONY: format
format: ## Format the code using ruff
uv run ruff check -n --fix
uv run ruff format
.PHONY: megalinter
megalinter:
docker run --rm -v "$(HERE):/tmp/lint" oxsecurity/megalinter:v8
The Testing section
We use pytest for testing with markers to categorize different types
of tests. This allows us to run specific test suites depending on what
we want to validate.
The test categories are:
unit: Standard unit tests that don’t require external servicesllm: Tests that interact with LLM APIs (can be slow and expensive)integration: Tests that require external infrastructure
Run the command make test for unit tests, make test-llm for LLM
tests, or make test-integration for integration tests.
.PHONY: test
test: ## Run only the unit tests
uv run pytest -m "unit"
.PHONY: test-llm
test-llm: ## Run only the llm tests
uv run pytest -m "llm"
.PHONY: test-integration
test-integration: ## Run only the integration tests
uv run pytest -m "integration"
The Build section
This section contains targets for building the project in different formats - from Python packages to Docker images.
The UV Build section of the Makefile
The build target creates a Python package using UV. It runs the
quality checks first to ensure we’re not building broken code.
.PHONY: build
build: check ## Build the deployment artifact
uv build
The Docker Build section of the Makefile
Docker builds create container images for deployment. We tag images
with both the latest tag and the git commit hash for traceability.
.PHONY: docker-build
docker-build: ## Build the FastAPI server Dockerfile
docker build -f Dockerfile -t metapy:latest -t metapy:$$(git rev-parse --short HEAD) .
.PHONY: docker-compose-build
docker-compose-build: ## Build all the local infra (docker-compose)
docker compose build
The Infrastructure section
This section manages the local development infrastructure using Docker Compose, including databases, message queues, and other services needed for development.
The Docker Compose section of the Makefile
Docker Compose is used to orchestrate local development services. The
up target starts all services, while down stops them.
.PHONY: up
up: ## Bring up all the local infra (docker-compose) and synthetic data
docker compose up
.PHONY: logs
logs:
docker compose logs
.PHONY: down
down: ## Bring down all the local infra (docker-compose)
docker compose down
.PHONY: down-clean
down-clean: ## Bring down all the local infra and delete volumes
@echo "Warning: Deleting volumes will lead to data loss. You will start from scratch and this may introduce bugs (e.g., if your code does not work with existing data in postgres)."
@read -p "Are you sure you want to proceed? (yes/no): " confirm; \
if [ "$$confirm" = "yes" ]; then \
docker compose down -v; \
echo "Volumes deleted."; \
else \
echo "Aborted."; \
fi
The Database Migration section of the Makefile
We use Alembic for database migrations. The migrate target runs all
pending migrations to bring the database schema up to date.
.PHONY: migrate
migrate: ## Run Alembic database migrations
uv run python -m alembic upgrade head
The FastAPI Server section of the Makefile
The server target runs the FastAPI application locally with tracing
enabled for observability.
.PHONY: server
server: ## Run the FastAPI server locally
ENABLE_TRACING=true uv run -m unravel.fastapi.main
The Deployment section
This section handles deployment to Fly.io with backup and rollback capabilities. The deployment process is designed to be safe and reversible.
The Fly.io Deployment section of the Makefile
The deployment process includes:
- Building the project
- Backing up the current production image
- Deploying the new version
- Auto-tagging successful deployments
.PHONY: backup-current-image
backup-current-image:
@echo "Backing up currently running image..."
@if [ -f .fly_image ]; then \
mv .fly_image .fly_image.backup; \
echo "✅ Backed up current .fly_image to .fly_image.backup"; \
fi
.fly_image:
@IMAGE=$$(flyctl image show --app metapy | awk 'NR>2 && NF>0 {print $$2"/"$$3":"$$4; exit}'); \
echo "$$IMAGE" > .fly_image; \
echo "✅ Current production image saved: $$IMAGE"
.PHONY: tag-deploy-internal
tag-deploy-internal: .fly_image
@if [ -f .can_tag ]; then \
TAG="fly-$$(date +%Y-%m-%d)"; \
IMAGE=$$(cat .fly_image); \
if git tag -m "$$(printf 'image: %s' "$$IMAGE")" "$$TAG" 2>/dev/null; then \
echo "✅ Tagged current commit as $$TAG"; \
rm .can_tag; \
else \
echo "⚠️ Tag $$TAG already exists, skipping tag creation"; \
echo "Image: $$IMAGE"; \
fi; \
fi
.PHONY: deploy-internal
deploy-internal:
@echo "Deploying to production..."
@if flyctl deploy --config fly.toml; then \
echo "✅ Deployment successful!"; \
touch .can_tag; \
else \
echo "❌ Deployment failed!"; \
if [ -f .fly_image.backup ]; then \
echo "Working image in: .fly_image.backup"; \
fi; \
exit 1; \
fi
.PHONY: deploy
deploy: build backup-current-image deploy-internal tag-deploy-internal ## Deploy with backup and auto-tagging
The Rollback section of the Makefile
The rollback feature allows you to quickly revert to the previous working version if a deployment fails.
.PHONY: rollback
rollback: ## Rollback to the previous version stored in backup
@if [ ! -f .fly_image.backup ]; then \
echo "❌ No backup image found (.fly_image.backup missing)"; \
echo "Cannot rollback without a backup image."; \
exit 1; \
fi
@BACKUP_IMAGE=$$(cat .fly_image.backup); \
echo "Rolling back to: $$BACKUP_IMAGE"; \
if flyctl deploy --config fly.toml --image "$$BACKUP_IMAGE"; then \
if [ -f .fly_image ]; then \
BAD_DEPLOY_IMAGE=$$(cat .fly_image); \
echo "Overwriting bad deploy image: $$BAD_DEPLOY_IMAGE"; \
fi; \
mv .fly_image.backup .fly_image; \
echo "✅ Rollback successful! Restored to: $$BACKUP_IMAGE"; \
else \
echo "❌ Rollback failed!"; \
exit 1; \
fi
The Build-only Deployment section of the Makefile
Sometimes you need to build an image without deploying it (useful for hot-swaps or migrations).
.PHONY: deploy-build-only
deploy-build-only:
flyctl deploy --config fly.toml --build-only
@echo "✅ Build-only Deploy complete! Please update .fly_image manually!"
.PHONY: prepare
prepare: deploy-build-only backup-current-image ## Build the latest code and upload image to fly.io. Useful in hot-swap and migration situations
.PHONY: deploy-reuse-image
deploy-reuse-image: .fly_image ## Deploy only config changes to production, reusing the latest image
@echo "Deploying config-only changes..."
@IMAGE=$$(cat .fly_image); \
echo "Using current production image: $$IMAGE"; \
if flyctl deploy --config fly.toml --image "$$IMAGE"; then \
echo "✅ Config deployment successful!"; \
else \
echo "❌ Config deployment failed!"; \
exit 1; \
fi
The Maintenance section
This section contains targets for maintaining the project, including dependency upgrades and cleanup operations.
The Dependency Upgrade section of the Makefile
We use UV’s built-in upgrade functionality to keep dependencies up-to-date. This is safer than manual editing of the pyproject.toml file.
.PHONY: upgrade-libs
upgrade-libs: ## Upgrade all the deps to their latest versions
uv sync --upgrade
The Cleaning section of the Makefile
Cleaning targets remove generated files and caches. The clean-cache
target is more aggressive and should only be used when necessary.
.PHONY: clean-cache
clean-cache: ## Clean UV Cache (only needed in extreme conditions)
@echo "Cleaning cache! This removes all downloaded deps!"
uv cache clean
.PHONY: clean
clean: ## Delete any existing artifacts
find . -type f -name "*.pyc" -delete
find . -type d -name "__pycache__" -exec rm -rf {} +
rm -rf build/
rm -rf dist/
rm -rf *.egg-info/