Cómo escribir un archivo MAKE: automatización de la configuración, compilación y prueba de Python

En este tutorial, repasaremos los conceptos básicos de Makefiles: expresiones regulares, notación de destino y secuencias de comandos bash. Escribiremos un archivo MAKE para un proyecto de Python y luego lo ejecutaremos con la utilidad make.

Introducción

Cuando desea ejecutar un proyecto que tiene múltiples fuentes, recursos, etc., debe asegurarse de que todo el código se vuelva a compilar antes de compilar o ejecutar el programa principal.

Por ejemplo, imagina que nuestro software se parece a esto:

1
2
3
main_program.source -> uses the libraries `math.source` and `draw.source`
math.source -> uses the libraries `floating_point_calc.source` and `integer_calc.source`
draw.source -> uses the library `opengl.source`

Entonces, si hacemos un cambio en opengl.source, por ejemplo, necesitamos volver a compilar tanto draw.source como main_program.source porque queremos que nuestro proyecto esté actualizado en todos los aspectos.

Este es un proceso muy tedioso y lento. Y debido a que todas las cosas buenas en el mundo del software provienen de que algún ingeniero es demasiado perezoso para escribir algunos comandos adicionales, nació Makefile.

Makefile usa la utilidad make, y si vamos a ser completamente precisos, Makefile es solo un archivo que alberga el código que usa la utilidad make. Sin embargo, el nombre Makefile es mucho más reconocible.

Makefile esencialmente mantiene su proyecto actualizado al reconstruir solo las partes necesarias de su código fuente cuyos “hijos” están desactualizados. También puede automatizar la compilación, las compilaciones y las pruebas.

En este contexto, un hijo es una biblioteca o un fragmento de código que es esencial para que se ejecute el código de su padre.

Este concepto es muy útil y se usa comúnmente con lenguajes de programación compilados. Ahora bien, puede que te estés preguntando:

¿No es Python un lenguaje interpretado?

Bueno, Python es técnicamente un lenguaje interpretado y compilado, porque para que interprete una línea de código, necesita precompilarlo en un código de bytes que no está codificado para una CPU específica y puede ejecutarse después el hecho.

Puede encontrar una explicación más detallada pero concisa en Blog de Ned Batchelder. Además, si necesita un repaso sobre cómo funcionan los Procesadores de lenguajes de programación, lo tenemos cubierto.

Desglose del concepto

Debido a que Makefile es solo una amalgama de múltiples conceptos, hay algunas cosas que necesitará saber para escribir un Makefile:

  1. Secuencias de comandos bash
  2. Expresiones regulares
  3. Notación de destino
  4. Comprender la estructura de archivos de su proyecto

Con estos en la mano, podrá escribir instrucciones para la utilidad make y automatizar su compilación.

Bash es un lenguaje de comandos (también es un Unix shell pero eso no es realmente relevante en este momento), que usaremos para escribir comandos reales o automatizar la generación de archivos.

Por ejemplo, si queremos hacer eco de todos los nombres de biblioteca al usuario:

1
2
3
4
DIRS=project/libs
for file in $(DIRS); do
    echo $$file
done

La notación de destino es una forma de escribir qué archivos dependen de otros archivos. Por ejemplo, si queremos representar las dependencias del ejemplo ilustrativo anterior en la notación de destino adecuada, escribiríamos:

1
2
3
main_program.cpp: math.cpp draw.cpp
math.cpp: floating_point_calc.cpp integer_calc.cpp
draw.cpp: opengl.cpp

En cuanto a la estructura de archivos, depende de su entorno y lenguaje de programación. Algunos IDE también generan automáticamente algún tipo de Makefile, y no necesitará escribirlo desde cero. Sin embargo, es muy útil comprender la sintaxis si desea modificarla.

A veces, modificar el Makefile predeterminado es incluso obligatorio, como cuando desea que OpenGL y CLion funcionen bien juntos.

Secuencias de comandos bash

Bash se usa principalmente para la automatización en las distribuciones de Linux, y es esencial para convertirse en un "asistente" de Linux todopoderoso. También es un lenguaje de escritura imperativo, lo que lo hace muy legible y fácil de entender. Tenga en cuenta que puede ejecutar bash en sistemas Windows, pero en realidad no es un caso de uso común.

Primero repasemos un programa simple "Hello World" en Bash:

1
2
3
4
5
6
# Comments in bash look like this

#!/bin/bash
# The line above indicates that we'll be using bash for this script
# The exact syntax is: #![source]
echo "Hello world!"

Al crear un script, dependiendo de su umask actual, es posible que el script en sí no sea ejecutable. Puede cambiar esto ejecutando la siguiente línea de código en su terminal:

1
chmod +x name_of_script.sh

Esto agrega permiso de ejecución al archivo de destino. Sin embargo, si desea otorgar permisos más específicos, puede ejecutar algo similar al siguiente comando:

1
chmod 777 name_of_script.sh

Más información sobre chmod en este enlace.

A continuación, repasemos rápidamente algunos conceptos básicos utilizando declaraciones y variables si simples:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/bin/bash

echo "What's the answer to the ultimate question of life, the universe, and everything?"
read -p "Answer: " number
# We dereference variables using the $ operator
echo "Your answer: $number computing..."
# if statement
# The double brackets are necessary, whenever we want to calculate the value of an expression or subexpression, we have to use double brackets, imagine you have selective double vision.
if (( number == 42 ))
then
    echo "Correct!"
    # This notation, even though it's more easily readable, is rarely used.
elif (( number == 41 || number == 43 )); then
    echo "So close!"
    # This is a more common approach
else
    echo "Incorrect, you will have to wait 7 and a half million years for the answer!"
fi

Ahora, existe una forma alternativa de escribir el control de flujo que en realidad es más común que las sentencias if. Como todos sabemos, los operadores booleanos se pueden usar con el único propósito de generar efectos secundarios, algo como:

1
++a && b++  

Lo que significa que primero incrementamos a y luego, dependiendo del idioma que estemos usando, verificamos si el valor de la expresión se evalúa como Verdadero (generalmente si un número entero es >0 o =/= 0 significa que su valor booleano es Verdadero). Y si es Verdadero, entonces incrementamos b.

Este concepto se llama ejecución condicional y se usa muy comúnmente en scripts bash, por ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash

# Regular if notation
echo "Checking if project is generated..."
# Very important note, the whitespace between `[` and `-d` is absolutely essential
# If you remove it, it'll cause a compilation error
if [ -d project_dir ]
then
    echo "Dir already generated."
else
    echo "No directory found, generating..."
    mkdir project_dir
fi

Esto se puede reescribir usando una ejecución condicional:

1
2
echo "Checking if project is generated..."
[ -d project_dir ] || mkdir project_dir 

O podemos llevarlo aún más lejos con expresiones anidadas:

1
2
echo "Checking if project is generated..."
[ -d project_dir ] || (echo "No directory found, generating..." && mkdir project_dir)

Por otra parte, el anidamiento de expresiones puede conducir a una madriguera de conejo y volverse extremadamente intrincado e ilegible, por lo que no se recomienda anidar más de dos expresiones como máximo.

Es posible que se sienta confundido por la extraña notación [ -d ] utilizada en el fragmento de código anterior, y no está solo.

El razonamiento detrás de esto es que originalmente las declaraciones condicionales en Bash se escribieron usando el comando test [EXPRESSION]. Pero cuando la gente comenzó a escribir expresiones condicionales entre paréntesis, Bash siguió, aunque con un truco muy descuidado, simplemente reasignando el carácter [ al comando test, con ] significando el final de la expresión, muy probablemente implementado después del hecho.

Debido a esto, podemos usar el comando test -d NOMBRE DE ARCHIVO que comprueba si el archivo proporcionado existe y es un directorio, como este [ -d NOMBRE DE ARCHIVO].

Expresiones regulares

Las expresiones regulares (regex para abreviar) nos brindan una manera fácil de generalizar nuestro código. O más bien para repetir una acción para un subconjunto específico de archivos que cumplan con ciertos criterios. Cubriremos algunos conceptos básicos de expresiones regulares y algunos ejemplos en el fragmento de código a continuación.

Nota: Cuando decimos que una expresión captura ( -> ) una palabra, significa que la palabra especificada está en el subconjunto de palabras que define la expresión regular:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Literal characters just signify those same characters
wikihtp -> wikihtp
wikihtp -> wikihtp

# The or (|) operator is used to signify that something can be either one or other string
Stack|Abuse -> Stack
            -> Abuse
Stack(Abuse|Overflow) -> wikihtp
                      -> StackOverflow

# The conditional (?) operator is used to signify the potential occurrence of a string
The answer to life the universe and everything is( 42)?...
    -> The answer to life the universe and everything is...
    -> The answer to life the universe and everything is 42...
    
# The * and + operators tell us how many times a character can occur
# * indicates that the specified character can occur 0 or more times
# + indicates that the specified character can occur 1 or more times 
He is my( great)+ uncle Brian. -> He is my great uncle Brian.
                               -> He is my great great uncle Brian.
# The example above can also be written like this:
He is my great( great)* uncle Brian.

Esto es solo lo mínimo que necesita para el futuro inmediato con Makefile. Aunque, a largo plazo, aprender expresiones regulares es una realmente buena idea.

Notación de destino {#notación de destino}

Después de todo esto, ahora podemos finalmente entrar en el meollo de la sintaxis de Makefile. La notación de destino es solo una forma de representar todas las dependencias que existen entre nuestros archivos fuente.

Veamos un ejemplo que tiene la misma estructura de archivos que el ejemplo del principio del artículo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# First of all, all pyc (compiled .py files) are dependent on their source code counterparts
main_program.pyc: main_program.py
    python compile.py $<
math.pyc: math.py
    python compile.py $<  
draw.pyc: draw.py
    python compile.py $<

# Then we can implement our custom dependencies
main_program.pyc: main_program.py math.pyc draw.pyc
    python compile.py $<
math.pyc: math.py floating_point_calc.py integer_calc.py
    python compile.py $<  
draw.pyc: draw.py opengl.py
    python compile.py $<

Tenga en cuenta que lo anterior es solo para aclarar cómo funciona la notación de destino. Se usa muy raramente en proyectos de Python como este, porque la diferencia en el rendimiento es en la mayoría de los casos insignificante.

La mayoría de las veces, los Makefiles se utilizan para configurar un proyecto, limpiarlo, tal vez brindar ayuda y probar sus módulos. El siguiente es un ejemplo de un Makefile de proyecto de Python mucho más realista:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# Signifies our desired python version
# Makefile macros (or variables) are defined a little bit differently than traditional bash, keep in mind that in the Makefile there's top-level Makefile-only syntax, and everything else is bash script syntax.
PYTHON = python3

# .PHONY defines parts of the makefile that are not dependant on any specific file
# This is most often used to store functions
.PHONY = help setup test run clean

# Defining an array variable
FILES = input output

# Defines the default target that `make` will to try to make, or in the case of a phony target, execute the specified commands
# This target is executed whenever we just type `make`
.DEFAULT_GOAL = help

# The @ makes sure that the command itself isn't echoed in the terminal
help:
    @echo "---------------HELP-----------------"
    @echo "To setup the project type make setup"
    @echo "To test the project type make test"
    @echo "To run the project type make run"
    @echo "------------------------------------"

# This generates the desired project file structure
# A very important thing to note is that macros (or makefile variables) are referenced in the target's code with a single dollar sign ${}, but all script variables are referenced with two dollar signs $${}
setup:
    
    @echo "Checking if project files are generated..."
    [ -d project_files.project ] || (echo "No directory found, generating..." && mkdir project_files.project)
    for FILE in ${FILES}; do \
        touch "project_files.project/$${FILE}.txt"; \
    done

# The ${} notation is specific to the make syntax and is very similar to bash's $() 
# This function uses pytest to test our source files
test:
    ${PYTHON} -m pytest
    
run:
    ${PYTHON} our_app.py

# In this context, the *.project pattern means "anything that has the .project extension"
clean:
    rm -r *.project

Con eso en mente, abramos la terminal y ejecutemos el Makefile para ayudarnos a generar y compilar un proyecto de Python:

ejecutando make con el makefile

Conclusión

Makefile y make pueden hacer su vida mucho más fácil y se pueden usar con casi cualquier tecnología o idioma.

Puede automatizar la mayor parte de su construcción y prueba, y mucho más. Y como se puede ver en el ejemplo anterior, se puede usar tanto con lenguajes interpretados como compilados.