Python se ha convertido en un lenguaje muy popular en los últimos años en parte por su facilidad de uso y en parte por la enorme cantidad de módulos disponibles para hacer prácticamente cualquier cosa. Quizás el empuje final fue su adopción en los sistemas de Inteligencia Artificial en los últimos años.
Sea cual sea el secreto el éxito de Python, se trata de un lenguaje potente y que merece la pena aprender. Incluyéndolo como lenguaje de script en nuestros programas añadimos fácilmente infinidad de posibilidades, abriendo la puerta a sofisticadas aplicaciones gracias a la enorme variedad de módulos disponibles. Curiosamente, Python no suele ser la principal elección como lenguaje de script para una aplicación y hacia el final del artículo veremos el porqué de ello. Lua o Lisp son elecciones más habituales, pero eso no significa que Python no pueda ser usado de la misma forma. En este artículo os contaremos como incluir Python como lenguaje de script o, si lo preferís, de extensión en vuestros programas. Anecdóticamente, haciendo esto, veremos una posible forma de convertir nuestros programas Python en ejecutables. La técnica que veremos es utilizada por algunas herramientas bastante populares… Ahí lo dejo.
Si… igual que todos esos videos en Internet… Pero esta vez contándoos como se hace y no como se usa un determinado programa.
Pre-requisitos
Es más que probable que en vuestro sistema Python este ya instalado. Y también es más que probable que tengáis todos los paquetes necesarios, pero en caso de que no que no cunda el pánico. Enseguida os decimos que tenéis que instalar. El código que mostraremos en este artículo ha sido probado con Python 3.11, así que debéis instalar el paquete de vuestra distribución que os proporcione esa versión o una mayor.
Además necesitaremos el paquete de desarrollo. El cual normalmente
tiene el mismo nombre pero añadiendo el sufijo -dev al
nombre. Para el caso de Debian y derivados podéis instalar los paquetes
necesarios con el siguiente comando.
$ sudo apt install python3-dev
Esto también instalará Python en caso de que no estuviera instalado.
También necesitaremos el compilador de C para poder generar nuestros programas. En caso de que no lo hayáis instalado ya, lo podéis hacer con el siguiente comando:
$ sudo apt install build-essential
Si utilizáis otras distribuciones seguro que os resulta muy secillo encontrar los paquetes necesarios (los nombres serán muy parecidos). Como alternativa, también podéis probar todo lo que os contemos en este artículo en un contenedor docker:
$ docker run --rm -it -v /tmp:/tmp/code --name python debian /bin/bash
El comando de arriba además de iniciar un contenedor con un sistema
de ficheros Debian, hace el directorio /tmp accesible en el
contenedor a través de la ruta /tmp/code para que podáis
intercambiar ficheros entre el host y el contenedor fácilmente.
Un intérprete mínimo
Vamos a comenzar despacito creando un intérprete Python mínimo, es decir, un programa C que pueda ejecutar código Python, lo cual es mucho más fácil de lo que podría parecer. Aquí está el programa.
#define PY_SSIZE_T_CLEAN
#include <Python.h>
int
main(int argc, char *argv[])
{
Py_Initialize ();
PyRun_SimpleString("from os import uname\n"
"print('Sistema', uname().sysname, uname().machine)\\n");
if (Py_FinalizeEx() < 0) {
exit(1);
}
return 0;
}El programa solo necesita tres funciones. Una para inicializar el
intérprete (Py_Initialize()), otra para liberar los
recursos al terminar (Py_FinalizeEx()), y una tercera
función para ejecutar cualquier cadena de caracteres como código
Python.
En este caso un sencillo programa que nos dice el sistema operativo y el tipo de máquina en el programa se está ejecutando.
La constante/macro PY_SSIZE_T_CLEAN es necesaria (en las
versiones más recientes de Python) para poder utilizar ciertas cadenas
de formato al interpretar parámetros en funciones. En un ratito veremos
de que se trata todo eso, pero en general, si hacéis esto con una
versión reciente de Python simplemente incluir ese #define
antes de incluir Python.h.
PY_SSIZE_T_CLEANhace que el tipo de la longitud de la lista de argumentos se defina internamente comoPy_ssize_ten lugar deintcomo sucedía en las versiones anteriores a 3.9.
Podemos compilar este programa utilizando la utilidad
python3-config para obtener los parámetros de compilación
correctos para nuestro sistema.
$ gcc -o example01 example01.c `python3-config --cflags` \ > `python3-config --ldflags --embed`
Ejecutando scripts
Hemos visto como ejecutar código almacenado en una cadena, lo cual es güay, pero no super güay. Así que vamos a modificar el programa para que pueda ejecutar cualquier script almacenado en un fichero. El programa es más sencillo de lo que podáis pensar, aunque quizás un poco inesperado.
#define PY_SSIZE_T_CLEAN
#include <Python.h>
int
main(int argc, char *argv[])
{
Py_Initialize ();
FILE *fd = fopen (argv[1], "rb");
PyRun_SimpleFile (fd, argv[1]);
fclose (fd);
if (Py_FinalizeEx() < 0) {
exit(120);
}
return 0;
}La función PyRun_SimpleFile hace la magia, pero tenemos
que pasar un objeto de tipo FILE* en lugar de el nombre del
fichero. Observad que siempre podéis leer el fichero en memoria y
utilizar PyRun_SimpleString para ejecutarlo, aunque eso
requiere más código.
La función
PyRun_SimpleFIlesolamente utiliza el segundo parámetro para mostrar errores, los datos los toma del primer parámetro
Para terminar con esto, y antes de profundizar en el API que nos ofrece Python, comentaros que existen muchas variaciones de estas funciones que acabamos de ver, las cuales podéis consultar en esta página.
BONUS: Ejecutando scripts remotos
Seguro que a alguno se le ha pasado por la cabeza… que tal si en lugar de un fichero usamos un socket?… Podríamos ejecutar scripts directamente desde un servidor remoto…. Esto mola bastante no?.
Para poder hacer esto, necesitamos una pequeña modificación de nuestro programa:
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int main(int argc, char *argv[]) {
int s;
struct sockaddr_in addr;
Py_Initialize ();
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(5555);
if ((s = socket (PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) return -1;
if (connect (s, (struct sockaddr*)&addr, 16) < 0) return -1;
FILE *fd = fdopen (s, "r");
PyRun_SimpleFile (fd, "REMOTO");
close (s);
if (Py_FinalizeEx() < 0) {
exit(120);
}
return 0;
}El programa ahora se conecta al puerto 5555 de
localhost y ejecuta lo que sea que reciba desde ahí. Lo
único que tenemos que hacer es utilizar la función fdopen
para convertir nuestro descriptor de ficheros (el socket) en un objeto
de tipo FILE *. Una vez hecho eso el resto del programa
funciona exactamente igual, pero tomando el código de la red en lugar de
desde un fichero.
Para probarlo, en un terminal podéis lanzar netcat con una línea como esta:
cat simple.py | nc -lN -p 5555
O si lo preferís podéis usar nuestra utilidad NetKitty:
cat simple.py | nk -server T,5555
Observad que con netcat tenemos que usar el flag -N el
cual le dice a netcat que cierre la conexión al recibir un
EOF (End Of File). Si no hacemos esto, el programa
no se ejecutará directamente.
Ahora podemos ejecutar nuestro programa para ejecutar cualquier
script remoto desde el servidor que queramos. Como nota final, una
alternativa a la llamada a fdopen sería utilizar esto:
dup2 (s, 0);
PyRun_SimpleFile (stdin, "REMOTO");Llamando a funciones Python
Hasta ahora hemos visto como integrar el intérprete Python en nuestro programa, pero todavía no sabemos como intercambiar información entre el programa principal y el código Python, lo cual limita muchísimo la utilidad de incluir el intérprete en nuestra aplicación. Lo ideal sería poder llamar a funciones Python desde nuestro programa C y viceversa. Empecemos por el primer caso.
Para ello vamos a crear una sencilla función de ejemplo en Python a la que llamaremos desde nuestro programa C. Algo como esto:
def foo (s, a, b):
print ("cadena: " + s)
return a + bLa función es bastante inútil, pero nos permite ver como podemos pasar distintos tipos de datos (cadenas de caracteres y enteros) y también como retornar esos valores al programa C.
Para poder ejecutar esta función desde nuestro programa C debemos hacer unas cuantas cosas:
- Primero importar el módulo o cargar el fichero
.pyen nuestro intérprete si lo preferís. - Segundo debemos encontrar la función que nos interesa dentro del módulo. En nuestro caso solo hemos definido una función, pero lo normal es que un módulo ofrezca varias funciones.
- Tercero, construir la lista de parámetros que queremos pasar a la función
- Cuarto, ejecutar la función
- Y finalmente, recuperar el valor retornado por la función.
Os mostramos el código completo para hacer todo esto y luego vamos paso a paso por las partes más relevantes:
(...)
PyObject *pModule = PyImport_ImportModule ("func");
if (pModule) {
/* Obtengamos la función a llamar */
PyObject *pFunc = PyObject_GetAttrString (pModule, "foo");
if (pFunc && PyCallable_Check(pFunc)) {
PyObject *pArgs = PyTuple_New (3); // 3 Parámetros
PyObject *pValue = PyUnicode_FromString ("Hola Mundo!");
PyTuple_SetItem (pArgs, 0, pValue);
pValue = PyLong_FromLong (10);
PyTuple_SetItem (pArgs, 1, pValue);
pValue = PyLong_FromLong (20);
PyTuple_SetItem (pArgs, 2,pValue);
pValue = PyObject_CallObject (pFunc, pArgs);
Py_DECREF(pArgs);
printf ("Resultado : %ld\n", PyLong_AsLong(pValue));
}
Py_XDECREF(pFunc);
Py_DECREF (pModule);
}
(...)Importando el Módulo y la Función
Como podéis ver este proceso es bastante inmediato. Para importar el
módulo utilizamos PyImport_ImportModule seguido de el
nombre del fichero sin la extensión. El objeto función lo podemos
obtener utilizando la función PyObject_GetAttrString.
En ambos casos las funciones nos devuelven un PyObject.
Prácticamente todo en Python es un objeto, así que ese será el tipo
fundamental de casi todas las llamadas. La función
PyObject_GetAttrString nos permite obtener referencias a
otros objetos en el módulo Python. Por ejemplo, si declaramos una
variable global de la siguiente forma:
bar = 10
def foo (s, a, b):
print ("cadena: " + s)
return a + bPodemos acceder al valor de la variable local de la siguiente forma:
PyObject *bar = PyObject_GetAttrString (pModule, "bar");
printf ("bar = %ld\n", PyLong_AsLong(bar));Que imprimirá el valor 10. La variable la podemos usar inmediatamente, pero para llamar a la función tenemos que hacer algunas cosas más. Por eso, es recomendable comprobar que el objeto que hemos obtenido es algo a lo que podemos llamar, es decir, una función y no una variable, por ejemplo. Esto lo hace la siguiente línea:
if (pFunc && PyCallable_Check(pFunc)) {Construyendo la lista de parámetros
Ahora que ya tenemos el objeto que representa la función que queremos llamar debemos construir la lista de parámetros. En Python, desde C, los parámetros se pasan como una tupla. Bueno, hay algunas opciones extras, pero a dejarlo así para no complicar la cosa más de lo necesario. Lo otro que debemos saber es que Python 3 usa Unicode para representar todas sus cadenas, y esa es la mejor forma de pasar cadenas de un lado a otro.
Sabiendo todo esto el código para construir la lista de parámetros es bastante obvio.
PyObject *pArgs = PyTuple_New (3); // 3 Parámetros
PyObject *pValue = PyUnicode_FromString ("Hola Mundo!");
PyTuple_SetItem (pArgs, 0, pValue);
pValue = PyLong_FromLong (10);
PyTuple_SetItem (pArgs, 1, pValue);
pValue = PyLong_FromLong (20);
PyTuple_SetItem (pArgs, 2,pValue);Lo primero que hacemos es crear una tupla de 3 elementos que contendrá los parámetros que queremos pasar a nuestra función. Recordad, una cadena de caracteres y dos enteros.
El objeto tupla contiene elementos de tipo PyObject*,
así que tendremos que generar un objeto por cada parámetro que queremos
pasar a la función. La objeto que contiene la cadena de caracteres lo
generamos usando la función PyUnicode_FromString y los dos
número utilizando PyLong_FromLong. Luego simplemente
tenemos que añadir los objetos a la tupla usando
PyTuple_SetItem.
De la misma forma que las cadenas de caracteres en Python se
almacenan en Unicode, los enteros se almacenan como long.
Veremos que para ciertas operaciones podemos indicar otros tipos de
enteros, pero internamente, todos son manejados como
long.
Llamando a la función y obteniendo el resultado
Ahora ya tenemos todo listo para invocar la función usando
PyObject_CallObject la cual acepta como parámetros la
función a ejecutar y la tupla conteniendo los parámetros que queremos
pasar y nos devolverá, como no, un objeto PyObject* con el
resultado de la función.
pValue = PyObject_CallObject (pFunc, pArgs);
printf ("Resultado : %ld\n", PyLong_AsLong(pValue));
Py_DECREF(pArgs);
Py_DECREF(pValue);En nuestro caso el resultado es un entero, así que podemos usar la
misma función PyLong_AsLong para convertir el valor en un
long que podamos utilizar en nuestro programa C.
Al final del fragmento de código anterior vemos dos llamadas a
Py_DECREF. Python mantiene internamente un contador de
referencias a cada objeto que maneja. Cada vez que es utilizado el valor
se incrementa y cuando hemos terminado con el lo tenemos que
decrementar. Cuando el contador llega a 0, los recursos asociados al
objeto se liberan. Así que en nuestro caso debemos decrementar las
referencia del valor retornado por la función y de los argumentos que ya
no necesitamos. En este caso, esas dos llamadas son equivalentes a
liberar la memoria asociada a ambos.
Os preguntaréis que pasa con el módulo y la función que también son
PyObjects… efectivamente, también debemos decrementar el
contador de la misma forma para que los recursos se liberen, una vez que
no necesitemos utilizarlos más. En el caso de estos dos objetos, esto lo
hacemos fuera del bloque que hace la llamada a la función:
if (pFunc && PyCallable_Check(pFunc)) {
(...)
}
Py_XDECREF(pFunc);
Py_DECREF (pModule);Para liberar la función estamos utilizando Py_XDECREF en
lugar de Py_DECREF. La diferencia entre ambos es que el
primero acepta punteros NULL y por lo tanto hace más
comprobaciones que la segunda función. En nuestro caso, tal y como esta
escrito el programa, es posible que pFunc tenga valor
NULL, así que usamos Py_XDECREF. El objeto
pModule no puede ser NULL puesto que nos
aseguramos al principio del programa de que así sea.
Como podéis ver, el proceso de llamar una función Python desde C es muy sencillo aunque un poco tedioso. Ahora veremos como llamar una función C desde Python
Creando un módulo
Para poder llamar código C desde Python, básicamente vamos a crear un módulo e importarlo en el intérprete de forma que el código Python que ejecutemos en el pueda acceder a las funciones de nuestro módulo. Suena más complicado de lo que realmente es.
Para crear un módulo llamado mimod en nuestro intérprete
Python, debemos añadir las siguientes líneas a nuestro programa antes de
main:
static PyObject* mimod_test1 (PyObject *self, PyObject *args);
static PyMethodDef mimodMethods[] = {
{"test1", mimod_test1, METH_VARARGS, "Función de prueba cutre"},
{NULL, NULL, 0, NULL},
};
static PyModuleDef _Module = {
PyModuleDef_HEAD_INIT, "mimod", NULL, -1, mimodMethods,
NULL, NULL, NULL, NULL
};
static PyObject* PyInit_mimod (void) {
return PyModule_Create (&_Module);
}Nuestro módulo tiene una única función llamada test1, la
cual está implementada con la función C mimod_test1. La
primera línea que añadimos es simplemente el prototipo de la función que
necesitamos para poder definir la lista de funciones (métodos realmente)
asociada a nuestro módulo. Esta lista se define en el siguiente bloque
usando la estructura PyMethodDef. En nuestro caso, hemos
definido una única función, pero podéis añadir tantas entradas como
queráis a este array para añadir funciones a vuestro módulo.
Cada entrada tiene los siguientes campos:
{"test1", mimod_test1, METH_VARARGS, "Función de prueba cutre"},El primero es el nombre de la función en el intérprete Python, el nombre que usaremos para invocarla desde nuestros scripts. El segundo parámetro es el puntero a la función C que se debe ejecutar cuando llamamos a esta función desde Python. El cuarto parámetro es documentación de la función y el tercer parámetro nos define la signatura (tipo y número de parámetros) de la función. Este campo puede tomar los siguientes valores:
METH_VARARGSes la forma más habitual de definir la función en el que la función espera dosPyObjects*. El primero es el objetoselfque, cuando se trata de un método en un objeto representa el objeto asociado, pero cuando se trata de una función contiene el objeto asociado al módulo que define la función. El segundo son los parámetros almacenados en una tupla.METH_KEYWORSeste debe ser combinado con otros flags comoMETH_VARARGS,METH_FASTCALLoMETH_METHODy básicamente permite invocar la función usandokeywords. La función recibe un parámetro extra que contiene loskeywordsasociados a los parámetros.METH_FASTCALLsolo admite parámetros posicionales. El primer parámetro seráself, el segundo es un array C dePyObject*y el tercero es el número de parámetrosMETH_METHODsolo se puede usar en combinación conMETH_FASTCALLyMET_KEYWORDSy añade la clase a la que pertenece el método como parámetro después deselfMETH_NOARGSdefine funciones que no reciben argumentos. Aún así, la función C sigue recibiendo el parámetroselfcomo primer parámetro.METH_Odefine métodos que recibe un único parámetro.
Terminando de definir el Módulo
Una vez que hemos definido la lista de funciones que queremos que
nuestro módulo incluya solo tenemos que rellenar una estructura
PyModuleDef para terminar su definición. Esta estructura
contiene los siguientes campos.
- El primer campo siempre debe ser
PyModuleDef_HEAD_INIT - El nombre del módulo
- Documentación para el módulo
- Este parámetro nos permite definir un bloque de memoria asociado al
módulo en el que almacenar información. Un valor de
-1indica que el módulo no necesita un bloque de memoria para almacenar información. Las funciones pueden acceder a esta información usando la funciónPyModule_GetState(). - El array con la definición de los métodos que expone el módulo
- El resto de parámetros se utilizan para añadir funcionalidades especiales al módulo, las cuales no vamos a poder comentar en este artículo. Si estáis interesados en profundizar en este tema, solo tenéis que hacérnoslo saber.
Una vez definida esta estructura, solo necesitamos una función
adicional para crear el módulo. En nuestro caso la hemos llamado
PyInit_mimod. Esta función además de crear el módulo, puede
realizar operaciones adicionales requeridas por el módulo, como reservar
memoria, a adquirir recursos.
Llegados a este punto solo tenemos que añadir el módulo al intérprete. Lo cual haremos con una línea como la siguiente:
PyImport_AppendInittab ("mimod", &PyInit_mimod)Como podéis ver, esta función recibe como parámetro la función que
hemos definido antes y que es la encargada de crear realmente el módulo.
Esta función hay que llamarla antes de Py_Initialize() para
que nuestro módulo se defina correctamente.
Implementando la función.
En este punto tenemos definido nuestro módulo mimod que
incluye una función llamada test1, la cual debe ser
implementada por una función C que hemos llamado
mimod_test1.
Esta función C hace lo mismo que la función Python que usamos en la primera parte del artículo. Recibe como parámetro una cadena de caracteres y dos números. Muestra la cadena de caracteres en pantalla y retorna la suma de los dos enteros. Super fácil no?. Esta es la pinta que tendría esa función:
static PyObject*
mimod_test1 (PyObject *self, PyObject *args) {
char *str;
int a, b;
if (!PyArg_ParseTuple (args, "sii", &str, &a, &b))
return NULL;
printf ("TEST1: Cadena: '%s' Suma: %d\n", str, a+b);
return PyLong_FromLong(a+b);
}Como ya sabemos, al tratarse de una función de tipo
METH_VARARGS recibe dos parámetros: self y los
parámetros almacenados en una tupla.
Las funciones de este tipo suelen obtener los parámetros utilizando
la función PyArg_ParseTuple que funciona como una especie
de scanf para los parámetros. En nuestro caso, la cadena de
formato sii le dice a la función que queremos extraer una
cadena de caracteres (s) y dos enteros i.
Podéis encontrar una lista completa de los valores que podéis usar en la
cadena de formato en la documentación
oficial.
Para retornar el valor debemos convertir el valor que queramos en un
objeto PyObject, así que usaremos las mismas funciones que
necesitamos para llamar a funciones Python desde C. En este caso, usamos
PyLong_FromLong para retornar un valor entero.
Probando nuestro nuevo Python
Ha llegado la hora de probar nuestro programa, para ello vamos a utilizar una versión modificada del nuestro script de prueba:
import mimod
def foo (s, a, b):
print ("cadena: " + s)
c = mimod.test1 ("Adios Mundo", 1234, 4321)
print (c)
return a + bEn este caso estamos importando mimod que es el módulo
que hemos creado y registrado en el intérprete Python de nuestra
aplicación. Mantenemos la función foo que llama nuestro
programa C, pero la extendemos para que llame a la función C
test1 que exporta nuestro módulo. Si compilamos y
ejecutamos el programa obtendremos algo como esto:
$ gcc -o example03 example03.c `python3-config --cflags` `python3-config --ldflags --embed` $ ./example03 ModuleNotFoundError: No module named 'func'
Oops, nuestro intérprete no es capaz de encontrar nuestro script.
Podemos solucionar esto de distintas maneras. Copiando el fichero en uno
de los directorios que contienen módulos Python, o modificando la
variable de entorno PYTHONPATH para que incluya el
directorio actual. Esta última opción es más limpia y sencilla:
$ PYTHONPATH=. ./example03 cadena: Hola Mundo! TEST1: Cadena: 'Adios Mundo' Suma: 5555 5555 Resultado : 30
Perfecto, ahora podemos escribir programas C capaces de ejecutar scripts escritos en Python que pueden llamar a funciones internas definidas en el propio programa.
Haciendo el programa independiente
En estos momentos, nuestro programa funciona perfectamente, pero esto
sucede solamente porque se dan dos condiciones. La primera es que el
intérprete de Python está instalado en nuestra máquina de desarrollo.
Obviamente, necesitamos el intérprete para escribir un programa que lo
use, sin embargo, nuestro ejemplo hasta el momento es un programa
dinámico que utiliza la librería libpython.so instalada en
nuestro sistema.
El segundo problema es que además de esa librería nuestro programa asume que existe una instalación de Python y, silenciosamente carga ciertos módulos del sistema al inicializar el intérprete.
Teniendo en cuenta todo esto, nuestro objetivo es generar un programa que funcione como esperamos sin necesidad de instalar Python. Algo así como lo que hacen esas herramientas que convierte programas Python en ejecutables. Haremos esto por partes solucionando los distintos problemas de uno en uno.
Configurando el Intérprete
Para poder conseguir nuestro objetivo lo primero que debemos hacer es tomar el control de la configuración de nuestro intérprete.
Las nuevas versiones de Python incluyen un sistema para configurar el
intérprete mucho más potente, permitiéndonos elegir en detalle como
queremos configurar nuestro entorno. Podéis consultar la documentación
sobre PyConfig para los detalles. Nosotros solamente vamos
a utilizar dos opciones.
Usando este sistema, lo primero que vamos a hacer es forzar a nuestro
intérprete a funcionar en modo aislado, es decir, sin acceder a los
distintos módulos o librerías instaladas en nuestro sistema. Además nos
interesa poder controlar el path de búsqueda de módulos, para no tener
que usar la variable PYTHONPATH cada vez que ejecutamos
nuestro programa… vale si, la podéis añadir a los scripts de
inicialización de bash, pero es mejor si nosotros mismos la
controlamos.
El código siguiente muestra como utilizar el sistema de configuración de Python para hacer estas dos cosas.
PyStatus status;
PyConfig config;
PyConfig_InitPythonConfig (&config);
config.module_search_paths_set = 1;
config.isolated = 1;
PyWideStringList_Append (&config.module_search_paths, L".");
status = Py_InitializeFromConfig(&config);
if (PyStatus_Exception(status)) {
PyConfig_Clear(&config);
Py_ExitStatusException(status);
}
PyConfig_Clear(&config);Como podéis ver, estamos activando el flag para definir el path de búsqueda de módulos y queremos ejecutar el intérprete en un entorno aislado. En el path de búsqueda incluimos el directorio actual para poder cargar nuestro módulo de prueba. En una aplicación real, probablemente queramos almacenar todos los scripts en un directorio concreto. Este es el lugar para hacerlo.
Si compilamos y ejecutamos obtendremos algo como esto:
$ ./example03
Python path configuration:
PYTHONHOME = (not set)
PYTHONPATH = (not set)
program name = 'python3'
isolated = 1
environment = 0
user site = 0
safe_path = 1
import site = 1
is in build tree = 0
stdlib dir = '/usr/lib/python3.11'
sys._base_executable = '/usr/bin/python3'
sys.base_prefix = '/usr'
sys.base_exec_prefix = '/usr'
sys.platlibdir = 'lib'
sys.executable = '/usr/bin/python3'
sys.prefix = '/usr'
sys.exec_prefix = '/usr'
sys.path = [
'.',
]
Fatal Python error: init_fs_encoding: failed to get the Python codec of the filesystem encoding
Python runtime state: core initialized
ModuleNotFoundError: No module named 'encodings'
Current thread 0x00007f1ce9222740 (most recent call first):
<no Python frame>
El programa muestra la configuración actual y el error que impide la
ejecución del mismo. En este caso indica que no puede encontrar el
módulo encodings. Uno de esos módulos que se usan en la
inicialización.
Añadiendo los módulos perdidos
Así que lo que vamos a hacer es incluir los módulos que necesitemos.
Para ello crearemos un directorio llamado libs (pero podéis
llamarlo como queráis) he iremos copiando en el los módulos que
necesitemos. Pero antes debemos añadir este nuevo directorio al path de
búsqueda de módulos. Algo como esto:
PyWideStringList_Append (&config.module_search_paths, L".");
PyWideStringList_Append (&config.module_search_paths, L"./libs");Ahora podemos copiar el módulo encodings que debería
encontrarse en /usr/lib/pythonX.Y donde X.Y es
la versión de Python que estemos usando.
Cada uno de los directorios puede contener varios ficheros de los
cuales puede que no necesitemos todos. En mi caso, y para este ejemplo,
en el directorio encodings solo necesito mantener los
ficheros:
__init__.py
aliases.py
utf_8.py
utf_8_sig.py
Siguiendo este proceso, mi ejemplo necesita un directorio de unos
236Kb. Tened en cuenta que en cuanto ejecutáis el programa la primera
vez, todos esos ficheros se compilan en byte codes y se almacenan en un
directorio llamado __pycache__ en cada subdirectorio de
lib. Podéis usar el contenido de ese directorio para ver
que módulos se utilizan y cuales no.
De esta forma hemos solucionado uno de nuestros problemas. Disponemos de una forma de incluir los módulos Python que necesitemos de una manera controlada. Ahora vamos a solucionar nuestro otro problema.
Generando un binario estático
Nuestro siguiente problema es incluir el intérprete de Python en nuestro programa de forma que podamos usar código Python independientemente de si está instalado o no.
Para ello solo necesitamos incluir la librería estática
libpython.a y algunas de sus referencias.
La forma de compilar el programa sería algo así:
gcc -static -fPIE -o test example03.c -I/usr/include/python3.11 \ -O2 -Wall -L/usr/lib/python3.11/config-3.11-x86_64-linux-gnu \ -L/usr/lib/x86_64-linux-gnu \ -lpython3.11 -lm -ldl -lz -lexpat
El comando anterior genera un monton de warnings del tipo:
/usr/bin/ld: /usr/lib/python3.11/config-3.11-x86_64-linux-gnu/libpython3.11.a(socketmodule.o): in function `socket_getservbyport': (.text.unlikely+0x3837): warning: Using 'getservbyport' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
Sin entrar en detalles, hay ciertas funciones de libC que seleccionan
el código a ejecutar de forma dinámica en tiempo de ejecución. Un
ejemplo típico son las funciones de resolución de nombre que dependiendo
de la configuración del sistema pueden hacer una petición DNS, una
consulta LDAP o leer el fichero hosts. Si vuestro programa
necesita utilizar alguna de esas funciones, quizás tendréis que
proporcionar una alternativa propia.
Así que llegados a este punto disponemos de un binario estático que incluye el intérprete Python y un directorio que contiene el conjunto mínimo de módulos que necesitamos… Que tal si los combinamos?
Incluyendo los módulos en el binario
Hay distintas formas de hacer esto, pero nosotros vamos a implementar la más directa, aunque, todo hay que decirlo, es bastante guay :). El proceso consta de tres pasos.
- Incluir el directorio de módulos en el binario
- Descomprimir los módulos al comenzar la ejecución del programa
- Borrar el directorio de módulos al terminar.
Para incluir los módulos, lo primero que haremos será crear un
fichero tar del directorio que los contiene. Recordad que
no necesitamos los ficheros compilados, si bien, si los incluimos el
programa se iniciará un poco más rápido. Si no queremos incluir el cache
podemos generar el fichero de la siguiente forma:
$ tar czvf libs.tgz --exclude=__pycache__ ./libs
Ahora convertiremos este fichero en un objeto ELF que podamos linkar con nuestra aplicación…. No me digas que esto no mola.
$ objcopy -I binary -O elf64-x86-64 libs.tgz libs.o
Ahora podemos recompilar nuestro programa incluyendo este nuevo objeto:
gcc -static -o example04 example04.c -I/usr/include/python3.11 -fPIE\ -O2 -Wall -L/usr/lib/python3.11/config-3.11-x86_64-linux-gnu \ -L/usr/lib/x86_64-linux-gnu -lpython3.11 -lm -ldl -lz -lexpat libs.o
Las única diferencia aquí es que estamos incluyendo nuestro fichero
tar al final, pero como un objeto ELF… Y os preguntaréis cual es la
diferencia de hacer esto a un simple cat >>. Esa es
una fantástica pregunta y la respuesta es esta:
$ readelf -Ws example04 | grep libs 22255: 0000000000bcc758 0 NOTYPE GLOBAL DEFAULT 21 _binary_libs_tgz_end 23049: 0000000000bc0b48 0 NOTYPE GLOBAL DEFAULT 21 _binary_libs_tgz_start 24221: 000000000000bc10 0 NOTYPE GLOBAL DEFAULT ABS _binary_libs_tgz_size
Sip, de esta forma tendremos acceso desde nuestro programa a estos símbolos que nos dirán en que parte de la memoria se ha cargado nuestro fichero tar. Enseguida veremos como usar estos símbolos:
Descomprimiendo el directorio de módulos
Para poder descomprimir el directorio de módulos, tendremos que volcar el fichero tar que ahora está en memoria añadiendo un poco de código al principio del programa. Algo como esto:
extern int _binary_libs_tgz_start;
extern int _binary_libs_tgz_size;
int
main(int argc, char *argv[])
{
mkdir ("/tmp/example04", 0777);
int fd = open ("/tmp/example04/libs.tgz", O_CREAT | O_WRONLY, 0777);
write (fd, &_binary_libs_tgz_start, (long)&_binary_libs_tgz_size);
close (fd);
system ("tar xzf /tmp/example04/libs.tgz -C /tmp/example04");
(...)Puesto que los símbolos vienen de otro módulo, debemos declararlos
como externos. Observad que lo que obtenemos es el símbolo, es decir, el
nombre que le damos a algún valor, o su posición en memoria. Así, la
forma de acceder a los valores asociados a los símbolos
_binaryXXX es utilizando el operador & .
Una vez que sabemos esto, lo único que tenemos que hacer es crear un
directorio temporal, volcar los datos en el y descomprimir el
fichero.
En este caso hemos usado system para llamar a
tar en lugar de descomprimir el fichero nosotros
mismos.
Al final del programa, antes de terminar, añadiremos una línea como esta para limpiar nuestros restos:
system ("rm -Rf /tmp/example04");Dos comentarios finales. El primero es recordaros que
system es una función peligrosa. En este caso estamos
usando cadenas estáticas, pero en general es mejor evitarla. Para el
tema que nos ocupa no es relevante como creamos o borramos directorios,
pero teníamos que hacer este comentario.
El segundo es que si tenéis demasiadas dependencias quizás no os
interese incluir libs.tgz de la forma en la que lo hemos
hecho, es decir, cargando todo su contenido en memoria simplemente para
volcarlo en el disco. En ese caso es mejor incluir el fichero al final
de nuestro ejecutable y utilizar algún tipo de información extra o marca
para extraer el contenido directamente del disco, ahorrando memoria…
pero el método usando objcopy era tan güay que no nos
pudimos resistir :).
CONCLUSIONES
Y esto es todo. Hemos visto como integrar un intérprete Python en nuestros programas en C y como podemos ejecutar código Python desde C y código C desde Python. También hemos visto como configurar nuestro intérprete y como generar un programa estático que incluye el intérprete de Python así como los módulos que podamos necesitar para su ejecución.
Aún cuando hemos intentado reducir los distintos elementos del programa al mínimo, el ejecutable resultante es de 9 Mb… un tamaño similar al que obtendríamos compilando nuestro módulo Python con Cython. Como podéis ver el programa crece mucho y manejar los módulos y librerías que dependen de él no es trivial. Quizás esta sea la razón por la que Python no es la versión más popular para incluir un lenguaje de script en un programa…
■
