Aurélien Gâteau

Using Python inside Makefiles

written on Saturday, February 22, 2025

Introduction

Make is an ubiquitous tool to build software. It reads a configuration file (traditionally called Makefile). This file lists the targets to build, their dependencies and the recipes to build the targets from their dependencies.

Traditionally, the recipes are written in shell script. In this article I present how some creative use of Make variables can let us write those in Python instead.

Why? Well, I thought it would be an interesting challenge!

Disclaimers:

  • While you might learn some useful tricks, I do not recommend doing this for serious work!
  • I only tested the examples from this article with GNU Make: they make not work with BSD Make or with the one provided by macOS.

Telling Make to use Python

Make lets you define the shell to use to run target recipes via the SHELL variable. You can use it to force Make to use bash instead of whatever /bin/sh points to. Nothing stops you from setting it to python:

# step1.mk
SHELL := python

all:
    print(f"Hello from {__name__}")

(step1.mk)

If we run this we get:

$ make -f step1.mk
print(f"Hello from {__name__}")
Hello from __main__

So far so good.

Multi-line commands

Let's try something fancier:

# step2.mk
SHELL := python

all:
    import sys
    print(sys.version)

(step2.mk)

Run it:

$ make -f step2.mk
import sys
print(sys.version)
Traceback (most recent call last):
  File "<string>", line 1, in <module>
NameError: name 'sys' is not defined. Did you forget to import 'sys'?
make: *** [step2.mk:6: all] Error 1

Bummer, that does not work. This is because Make executes each line of the recipe separately, so in our case it ran:

  1. python -c "import sys"
  2. python -c "print(sys.version)"

ℹ️The -c is defined in the .SHELLFLAGS variable.

This is not what we want. Fortunately Make provides a way to change this behavior. When the Makefile contains the .ONESHELL: pseudo target, Make passes the whole recipe at once to the shell.

ℹ️You can use .ONESHELL: to write complex shell recipes without having to end each line with \.

Let's modify our Makefile:

# step3.mk
SHELL := python

.ONESHELL:

all:
    import sys
    print(sys.version)

(step3.mk)

Run it:

$ make -f step3.mk
import sys
print(sys.version)
3.12.3 (main, Feb  4 2025, 14:48:35) [GCC 13.3.0]

Much better.

Using automatic variables

Automatic variables can be used in a Makefile to refer to the file name of a target ($@), the list of its prerequisites ($^), the first prerequisite ($<) and many other elements.

Since they are search-and-replaced inside the recipe they should just work. Let's try:

# step4.mk
SHELL := python

.ONESHELL:

all: foo

foo:
    from pathlib import Path
    path = Path("$@")
    path.write_text("I am $@\n")

(step4.mk)

Run it:

$ make -f step4.mk
from pathlib import Path
path = Path("foo")
path.write_text("I am foo\n")

$ cat foo
I am foo

That works. Let's move on to something trickier.

Python blocks

If we want to write some serious Python-based recipes, at some point we are going to need if or for loops. Python uses indentation to define blocks. Can we make this work inside a Makefile?

# step5.mk
SHELL := python

.ONESHELL:

all: foo

foo:
    from pathlib import Path
    with Path("$@").open("w") as fp:
        for line in range(1, 4):
            fp.write(f"I am line {line}\n")

(step5.mk)

Run it:

$ make -f step5.mk
from pathlib import Path
with Path("foo").open("w") as fp:
for line in range(1, 4):
        fp.write(f"I am line {line}\n")
  File "<string>", line 3
    for line in range(1, 4):
    ^
IndentationError: expected an indented block after 'with' statement on line 2
make: *** [step5.mk:9: foo] Error 1

No luck. It looks like Make removes all leading tabs from the recipe lines before running it. In step5.mk all indentation is done using real tabs, so there are two tabs at the start of the for line. Here is the same file with tabs replaced with --->:

# step5.mk
SHELL := python

.ONESHELL:

all: foo

foo:
--->from pathlib import Path
--->with Path("$@").open("w") as fp:
--->--->for line in range(1, 4):
--->--->--->fp.write(f"I am line {line}\n")

What about using spaces after the first tab, like this?

# step6.mk
SHELL := python

.ONESHELL:

all: foo

foo:
--->from pathlib import Path
--->with Path("$@").open("w") as fp:
--->    for line in range(1, 4):
--->        fp.write(f"I am line {line}\n")

(step6.mk)

Let's try:

$ make -f step6.mk
from pathlib import Path
with Path("foo").open("w") as fp:
    for line in range(1, 4):
        fp.write(f"I am line {line}\n")

$ cat foo
I am line 1
I am line 2
I am line 3

Yes, it works. If that sounds like a bad idea to you, then you are not alone...

A slightly less cursed approach is to use the .RECIPEPREFIX variable. This variable tells Make we want to use another character to indent our recipes:

# step7.mk
SHELL := python

.RECIPEPREFIX := >

.ONESHELL:

all: foo

foo:
>from pathlib import Path
>with Path("$@").open("w") as fp:
>    for line in range(1, 4):
>        fp.write(f"I am line {line}\n")

(step7.mk)

This works as well. Whether it's better or worse is up to you to decide... It is certainly going to look weird to others. In fact I had to disable syntax-highlighting on this snippet because Pygment, the syntax highlighter used for this site, got very confused…

Takeaways

With some combination of SHELL, .ONESHELL:, some creative use of tabs and spaces or of .RECIPEPREFIX it turns out one can use Python inside a Makefile, for maximum fun and confusion!

This post was tagged makefile and tips