musings

A Makefile for Python/Flask web apps

When I work on a Flask application, I need to be able to do the following:

None of the actions are complicated, but I want to type less whenever possible. I'd also like the package isolation benefits of a virtual environment without having to think about activating it in different terminals or project directories.

For all this, I turn to Make. It lets me create commands that are shorter, easier to remember, and local to the project1.

Getting started

First, we create a file called Makefile with one target:

hello:
	echo hello
	echo world

Targets generally look like:

<target>: <dependency_1> ... <dependency_n>
	<command_1>
	...
	<command_n>

If we run the hello target, we should see the two echo command results:

$ make hello
echo hello
hello
echo world
world

Silencing the output

Let's run that target again:

$ make hello
echo hello
hello
echo world
world

Notice that each command is printed (echoed) first, and then executed. It's not a big deal in this example, but the console would be very crowded when running targets that have a lot more than just two commands.

If we add an empty .SILENT target at the top of the file:

.SILENT:

hello:
	echo hello
	echo world

The echoing is suppressed and we only see the commands' results:

$ make hello
hello
world

Preventing default runs

Here's another bit of housekeeping before we really get going.

If you run make without a target2, it will run the file's first target:

$ make
hello
world

$ make hello
hello
world

This is fine for now, but could be a problem if the first target is a destructive action (like cleaning a directory).

To prevent this, we add an empty default target:

.SILENT:

default:

hello:
	echo hello
	echo world

And now running make on its own does nothing:

$ make

$ make hello
hello
world

Working with Flask

Writing the application

Our application will be an API server with a GET /hello endpoint.

First, we create a requirements.txt file for the dependencies:

Flask==3.1.0

Then, app.py for the code:

from flask import Flask, jsonify

app = Flask(__name__)

@app.get("/hello")
def hello():
    return jsonify({ "msg": "Hello, World!" })

Now we're ready to add Make targets to facilitate the development process.

Creating the virtual environment

venv:
	echo "Creating '.venv' python virtual environment..."
	python3 -m venv .venv

This creates a virtual environment named .venv, if it doesn't already exist:

$ make venv

Installing dependencies

install:
	echo "Installing python packages..."
	. .venv/bin/activate
	pip install -r requirements.txt

This activates the virtual environment and installs the packages listed in requirements.txt.

But before running it, we need to know more about how Make runs a target's commands. The default behavior is that each one runs in a separate shell, so make install will:

  1. Echo a message in shell A
  2. Activate the virtual environment in shell B
  3. Install the dependencies in shell C, but without an active virtual environment, they will be installed in the base environment instead

To fix this, we add an empty .ONESHELL target at the top of the file:

.ONESHELL:
.SILENT:

That tells Make to run a target's commands in the same shell.

Now we can run the target and see the correct ./.venv/* installation directory:

$ make install
Creating '.venv' python virtual environment...
Requirement already satisfied: Flask==3.1.0 in ./.venv/lib/python3.10/site-packages (from -r requirements.txt (line 1)) (3.1.0)

Notice that there's no (.venv) at the end of the console output to indicate that the virtual environment is active.

This is what we normally expect:

$ . .venv/bin/activate
(.venv) 

$ echo inside
inside
(.venv)

$ deactivate

$ echo outside
outside

The reason why it's missing is that the virtual environment is not active. We activated it from within the install target, which Make runs in a shell that closes once the target stops running. This means that targets written this way will run Python commands inside a virtual environment that Make will deactivate for us.

Running the application

run:
	echo "Running the app..."
	. .venv/bin/activate
	flask --app app run

Similarly to install, this target activates the virtual environment and then runs the Flask application:

$ make run
$ curl http://localhost:5000/hello
{"msg":"Hello, World!"}

And that's about it! A few Make targets covering my regular Flask actions.

If this isn't enough, we can always add or update targets to get more functionality.

More changes to the Makefile

Initializing the project

Here's an example of adding dependencies to a target:

setup: venv install

Remember that a target run its dependencies before its commands. There are no commands here and both dependencies are targets we created earlier, so running this target is equivalent to:

$ make venv
$ make install

In other words, it's a shortcut for creating the virtual environment and installing dependencies:

$ make setup

Deleting the virtual environment

clean:
	echo "Deleting '.venv' python virtual environment..."
	rm -rf .venv

This deletes the .venv virtual environment, if it exists:

$ make clean

Calling make install will install or update packages based on changes to requirements.txt, but it won't delete any, so you can make clean and then make setup to delete packages you don't need anymore.

Command aliases

The install and run targets both begin by activating the virtual environment, so this appears twice in the Makefile:

. .venv/bin/activate

And we'd have to keep duplicating it for every new target we want to run inside the virtual environment. But we can create a variable to use as an alias instead.

Add this variable before the first target:

ACTIVATE_VENV := . .venv/bin/activate

And then substitute the alias for the activation commands in the targets:

install:
	echo "Installing python packages..."
	$(ACTIVATE_VENV)
	pip install -r requirements.txt

run:
	echo "Running the app..."
	$(ACTIVATE_VENV)
	flask --app app run

Conclusion

I went over how I write a basic Makefile for Flask projects, but it can easily adapt to another Python framework or a different language altogether. Make is a good solution to automate building, compiling, dependency management (as I've shown), or even running your own tasks inside CI tools.

And I've found that a project is easier to approach after replacing its common workflows with Make — I know I'd rather see make setup in a README than its full commands and a virtual environment explanation/reminder.

Full example

Here's the complete Makefile:

.ONESHELL:
.SILENT:

ACTIVATE_VENV := . .venv/bin/activate

default:
	cat Makefile

setup: venv install

clean:
	echo "Deleting '.venv' python virtual environment..."
	rm -rf .venv

venv:
	echo "Creating '.venv' python virtual environment..."
	python3 -m venv .venv

install:
	echo "Installing python packages..."
	$(ACTIVATE_VENV)
	pip install -r requirements.txt

run:
	echo "Running the app..."
	$(ACTIVATE_VENV)
	flask --app app run
  1. I don't always want to add commands to my global bash profile. Using Make allows me to create rules and targets that are specific to the project at hand.

  2. I find it surprisingly easy to press ENTER in between typing make and my intended target.

#make #python