08 noviembre 2021

Cómo crear ejecutables de aplicaciones Python mediante PyOxidizer

Python es un gran lenguaje para desarrollar. El problema es que carece de un soporte adecuado para empaquetar y distribuir aplicaciones al usuario final. Pypi y los paquetes wheel están más enfocados a dar soporte a los desarrolladores para que estos instalen las dependencias de sus aplicaciones, pero para los usuarios finales es muy complicado usar pip y virtualenv para instalar una aplicación python. 

Hay algunos proyectos intentando solventar ese problema, como por ejemplo PyInstallerpy2exe o cx_Freeze. Yo mismo mantengo la aplicación vdist, que está muy enfocada a ese problema e intenta resolverlo creando paquetes debian, rpm y archlinux para aplicaciones Python. En este artículo vamos a analizar PyOxidizer. Esta herramienta está desarrollada en un lenguaje que me está gustando mucho (Rust) y sigue una aproximación similar a la de vdist al empaquetar tu aplicación junto a una distribuión de python, pero además compila el paquete entero un executable binario. 

Para estructurar este tutorial, vamos compilar mi proyecto Cifra. Lo puedes clonar en este punto point para seguir este tutorial paso a paso.

El primer punto a tener en cuenta es que PyOxidizer empaqueta tu aplicación con una distribución personalizada de python 3.8 o 3.9, por lo que la aplicación deberá ser compatible con esas versiones. Te hará falta un framework de compilación en C para compilar con PyOxidizer. Si no contases con dicho framework, PyOxidiczer sacaría instrucciones para instalar uno. PyOxidizer usa también el framework de Rust, pero lo descarga en segundo plano por tí, así que no es una dependencia de la que te tengas que preocupar.

Instalación de PyOxidizer

Hay varias maneras de instalar PyOxidizer (descargando una release compilada de su página de GitHub, compilando desde el código fuente) pero creo que la manera más limpia es instalarlo en el virtualenv del proyecto usando pip.

Supongamos que hemos clonado el proyecto de Cifra (en mi caso en la ruta ~/Project/cifra), que dentro de esta carpeta hemos creado un virtualenv en una carpeta venv y que hemos activado dicho virtualenv. En ese caso se puede instalar PyOxidizer dentro de ese virtualenv haciendo:

(venv)dante@Camelot:~/Projects/cifra$ pip install pyoxidizer
Collecting pyoxidizer
Downloading pyoxidizer-0.17.0-py3-none-manylinux2010_x86_64.whl (9.9 MB)
|████████████████████████████████| 9.9 MB 11.5 MB/s
Installing collected packages: pyoxidizer
Successfully installed pyoxidizer-0.17.0

(venv)dante@Camelot:~/Projects/cifra$

Se puede comprobar que hemos instalado PyOxidizer correctamente haciendo:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer --help
PyOxidizer 0.17.0
Gregory Szorc <gregory.szorc@gmail.com>
Build and distribute Python applications

USAGE:
pyoxidizer [FLAGS] [SUBCOMMAND]

FLAGS:
-h, --help
Prints help information

--system-rust
Use a system install of Rust instead of a self-managed Rust installation

-V, --version
Prints version information

--verbose
Enable verbose output


SUBCOMMANDS:
add Add PyOxidizer to an existing Rust project. (EXPERIMENTAL)
analyze Analyze a built binary
build Build a PyOxidizer enabled project
cache-clear Clear PyOxidizer's user-specific cache
find-resources Find resources in a file or directory
help Prints this message or the help of the given subcommand(s)
init-config-file Create a new PyOxidizer configuration file.
init-rust-project Create a new Rust project embedding a Python interpreter
list-targets List targets available to resolve in a configuration file
python-distribution-extract Extract a Python distribution archive to a directory
python-distribution-info Show information about a Python distribution archive
python-distribution-licenses Show licenses for a given Python distribution
run Run a target in a PyOxidizer configuration file
run-build-script Run functionality that a build script would perform


(venv)dante@Camelot:~/Projects/cifra$


Configuración de PyOxidizer 

Ahora hay que crear un fichero de configuración inicial de PyOxidizer en la carpeta raiz del proyecto:

(venv)dante@Camelot:~/Projects/cifra$ cd ..
(venv) dante@Camelot:~/Projects$ pyoxidizer init-config-file cifra
writing cifra/pyoxidizer.bzl

A new PyOxidizer configuration file has been created.
This configuration file can be used by various `pyoxidizer`
commands

For example, to build and run the default Python application:

$ cd cifra
$ pyoxidizer run

The default configuration is to invoke a Python REPL. You can
edit the configuration file to change behavior.

(venv)dante@Camelot:~/Projects$

Se ha generado un fichero pyoxidizer.bzl que está escrito en Starlark, un dialecto de python para ficheros de configuración, así que nos sentiremos como en casa con ellos. Sin embargo, ese fichero de configuración es bastante largo y aunque está completamente comentado, al principio no está claro como hacer los ajustes necesarios. PyOxidizer es bastante completo pero puede resultar intimidante para alguien que se acerque a él por primera vez para hacer algo sencillo. Tras recorrer toda la web de documentación de PyOxidizer, creo que esta sección es la mñas útil para personalizar el fichero de configuración de PyOxidizer.

Hay muchas aproximaciones para compilar binarios con PyOxidizer. Vamos a suponer que tenemos un fichero setup.py de setuptools para nuestro proyecto, con eso podemos pedirle a PyOxidizer que ejecute ese setup.py dentro de su versión personalizada de python. Para hacer eso, hay que añadir la línea siguiente antes de la línea de return en la función make_exe() de pyoxidizer.bzl.

# Run cifra's own setup.py and include installed files in binary bundle.
exe.add_python_resources(exe.setup_py_install(package_path=CWD))

En el parámetro package_path hay que poner la ruta del fichero setup.py. Yo he incluido un argumento CWD porque estoy suponiendo que pyoxidizer.bzl y setup.py están en la misma carpeta.

Lo siguiente a personalizar en pyoxidizer.bzl es que por defecto intenta construir un ejecutable MSI para Windows, pero en Linux querremos un ejecutable ELF. Por eso, primero hay que comentar la línea cercana al final que registro un target para construir un instalador MSI.

#register_target("msi_installer", make_msi, depends=["exe"])

Necesitamos también un punto de entrada para nuestra aplicación. Estaría bien que PyOxidizer cogiese el punto de entrada del setup.py pero no es así. En vez de eso tenemos que facilitarlo manualmente a través del fichero de configuración pyoxidizer.bzl. En nuestro ejemplo sólo hay que encontrar la línea de la función make_exec() donde se crea la variable python_config y poner justo después:

python_config.run_command = "from cifra.cifra_launcher import main; main()"


Compilar a binarios ejecutables

Ahora ya se puede ejecutar "pyoxidizer build" y pyoxidizer comenzará a empaquetar y compilar nuestra aplicación.

Pero Cifra tiene un problema particular en este punto. Si intentas compilar Cifra con la configuración que hemos visto hasta ahora nos encontraremos con el siguiente error:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
error[PYOXIDIZER_PYTHON_EXECUTABLE]: adding PythonExtensionModule<name=sqlalchemy.cimmutabledict>

Caused by:
extension module sqlalchemy.cimmutabledict cannot be loaded from memory but memory loading required
--> ./pyoxidizer.bzl:272:5
|
272 | exe.add_python_resources(exe.setup_py_install(package_path=CWD))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ add_python_resources()


error: adding PythonExtensionModule<name=sqlalchemy.cimmutabledict>

Caused by:
extension module sqlalchemy.cimmutabledict cannot be loaded from memory but memory loading required

(venv)dante@Camelot:~/Projects$

PyOxidizer intenta incluir todas las dependencias de tu aplicación dentro del binalrio resultante (modo in-memory). Esto está muy bien porque acabas con un único binario a distribuir y se mejora el rendimiento al cargar esas dependencias. El problema es que no todos los paquetes que hay por ahí admiten ser incluidos de esa manera. En ese caso SQLAlchemy no se deja incluir así.

En ese caso, las dependencias deben almacenarse junto al binario producido (modo filesystem-relative). PyOxidizer enlazará esas dependencias dentro del binario usando sus ubicaciones relativas al fichero binario creado, por eso a la hora de distribuir debemos empaquetar el binario y sus dependencias manteniendo sus ubicaciones relativas.

Una manera de resolver el problema es pedirle a PyOxidizer que use el modo in-memory por defecto y recurra al modo filesystem-relative cuando no le quede más remedio. Para hacerlo hay que descomentar las dos líneas siguientes a la función make_exe() en el fichero de configuración:

# Use in-memory location for adding resources by default.
policy.resources_location = "in-memory"

# Use filesystem-relative location for adding resources by default.
# policy.resources_location = "filesystem-relative:prefix"

# Attempt to add resources relative to the built binary when
# `resources_location` fails.
policy.resources_location_fallback = "filesystem-relative:prefix"

Haciendo esto debería conseguir que tu aplicación tuviese este fallo, pero Cifra tiene otros más adelante en el proceso de compilación:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
adding extra file prefix/sqlalchemy/cresultproxy.cpython-39-x86_64-linux-gnu.so to .
installing files to /home/dante/Projects/cifra/./build/x86_64-unknown-linux-gnu/debug/install
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "cifra.cifra_launcher", line 31, in <module>
File "cifra.attack.vigenere", line 21, in <module>
File "cifra.tests.test_simple_attacks", line 2, in <module>
File "pytest", line 5, in <module>
File "_pytest.assertion", line 9, in <module>
File "_pytest.assertion.rewrite", line 34, in <module>
File "_pytest.assertion.util", line 13, in <module>
File "_pytest._code", line 2, in <module>
File "_pytest._code.code", line 1223, in <module>
AttributeError: module 'pluggy' has no attribute '__file__'
error: cargo run failed

(venv)dante@Camelot:~/Projects$

Este error parece algo relacionado con este matiz de PyOxidizer

Para resolver este proyecto no queda otro remedio que obligar al modo filesystem-related en todos los casos:

# Use in-memory location for adding resources by default.
# policy.resources_location = "in-memory"

# Use filesystem-relative location for adding resources by default.
policy.resources_location = "filesystem-relative:prefix"

# Attempt to add resources relative to the built binary when
# `resources_location` fails.
# policy.resources_location_fallback = "filesystem-relative:prefix"

Ahora la compilación funcionará de un tirón:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
installing files to /home/dante/Projects/cifra/./build/x86_64-unknown-linux-gnu/debug/install
(venv)dante@Camelot:~/Projects/cifra$ls build/x86_64-unknown-linux-gnu/debug/install/
cifra prefix
(venv)dante@Camelot:~/Projects$

Como se puede ver, PyOxidizer generó un ejecutable ELF para nuestra aplicación y guardó todas sus dependencias en la carpeta prefix:

(venv)dante@Camelot:~/Projects/cifra$ ls build/x86_64-unknown-linux-gnu/debug/install/prefix
abc.py concurrent __future__.py _markupbase.py pty.py sndhdr.py tokenize.py
aifc.py config-3 genericpath.py mimetypes.py py socket.py token.py
_aix_support.py configparser.py getopt.py modulefinder.py _py_abc.py socketserver.py toml
antigravity.py contextlib.py getpass.py multiprocessing __pycache__ sqlalchemy traceback.py
argparse.py contextvars.py gettext.py netrc.py pyclbr.py sqlite3 tracemalloc.py
ast.py copy.py glob.py nntplib.py py_compile.py sre_compile.py trace.py
asynchat.py copyreg.py graphlib.py ntpath.py _pydecimal.py sre_constants.py tty.py
asyncio cProfile.py greenlet nturl2path.py pydoc_data sre_parse.py turtledemo
asyncore.py crypt.py gzip.py numbers.py pydoc.py ssl.py turtle.py
attr csv.py hashlib.py opcode.py _pyio.py statistics.py types.py
base64.py ctypes heapq.py operator.py pyparsing stat.py typing.py
bdb.py curses hmac.py optparse.py _pytest stringprep.py unittest
binhex.py dataclasses.py html os.py pytest string.py urllib
bisect.py datetime.py http _osx_support.py queue.py _strptime.py uuid.py
_bootlocale.py dbm idlelib packaging quopri.py struct.py uu.py
_bootsubprocess.py decimal.py imaplib.py pathlib.py random.py subprocess.py venv
bz2.py difflib.py imghdr.py pdb.py reprlib.py sunau.py warnings.py
calendar.py dis.py importlib __phello__ re.py symbol.py wave.py
cgi.py distutils imp.py pickle.py rlcompleter.py symtable.py weakref.py
cgitb.py _distutils_hack iniconfig pickletools.py runpy.py _sysconfigdata__linux_x86_64-linux-gnu.py _weakrefset.py
chunk.py doctest.py inspect.py pip sched.py sysconfig.py webbrowser.py
cifra email io.py pipes.py secrets.py tabnanny.py wsgiref
cmd.py encodings ipaddress.py pkg_resources selectors.py tarfile.py xdrlib.py
codecs.py ensurepip json pkgutil.py setuptools telnetlib.py xml
codeop.py enum.py keyword.py platform.py shelve.py tempfile.py xmlrpc
code.py filecmp.py lib2to3 plistlib.py shlex.py test_common zipapp.py
collections fileinput.py linecache.py pluggy shutil.py textwrap.py zipfile.py
_collections_abc.py fnmatch.py locale.py poplib.py signal.py this.py zipimport.py
colorsys.py formatter.py logging posixpath.py _sitebuiltins.py _threading_local.py zoneinfo
_compat_pickle.py fractions.py lzma.py pprint.py site.py threading.py
compileall.py ftplib.py mailbox.py profile.py smtpd.py timeit.py
_compression.py functools.py mailcap.py pstats.py smtplib.py tkinter

(venv)dante@Camelot:~/Projects$

Si quisieras nombrar a esa carpeta con otro nombre más autoexplicativo, sólo habría que cambiar "prefix" por el nombre que quisieras en el fichero de configuración. Por ejemplo, para llamarla "lib":

# Use in-memory location for adding resources by default.
# policy.resources_location = "in-memory"

# Use filesystem-relative location for adding resources by default.
policy.resources_location = "filesystem-relative:lib"

# Attempt to add resources relative to the built binary when
# `resources_location` fails.
# policy.resources_location_fallback = "filesystem-relative:prefix"

Podemos ver cómo ha cambiado el nombre de la carpeta de dependencias al recompilar:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
installing files to /home/dante/Projects/cifra/./build/x86_64-unknown-linux-gnu/debug/install
(venv)dante@Camelot:~/Projects/cifra$ls build/x86_64-unknown-linux-gnu/debug/install/
cifra lib
(venv)dante@Camelot:~/Projects$


Compilando para múltiples distribuciones

Hasta ahora, hemos conseguido un binario que puede ser ejecutado en una distribución como la que hayamos usado para compilar. El problema es que el binario generado puede fallar si se ejecuta en otras distribuciones:

(venv)dante@Camelot:~/Projects/cifra$ ls build/x86_64-unknown-linux-gnu/debug/install/
cifra lib
(venv) dante@Camelot:~/Projects/cifra$ docker run -d -ti -v $(pwd)/build/x86_64-unknown-linux-gnu/debug/install/:/work ubuntu
36ad7e78c00f90172bd664f9a045f14acc2138b2ee5b8ac38e7158aa145d4965
(venv) dante@Camelot:~/Projects/cifra$ docker attach 36ad
root@36ad7e78c00f:/# ls /work
cifra lib
root@36ad7e78c00f:/# /work/cifra --help
usage: cifra [-h] {dictionary,cipher,decipher,attack} ...

Console command to crypt and decrypt texts using classic methods. It also performs crypto attacks against those methods.

positional arguments:
{dictionary,cipher,decipher,attack}
Available modes
dictionary Manage dictionaries to perform crypto attacks.
cipher Cipher a text using a key.
decipher Decipher a text using a key.
attack Attack a ciphered text to get its plain text

optional arguments:
-h, --help show this help message and exit

Follow cifra development at: <https://github.com/dante-signal31/cifra>
root@36ad7e78c00f:/# exit
exit

(venv)dante@Camelot:~/Projects$

Aquí nuestro binario ha funcionado en otro sistema Ubuntu porque lo he compilado en mi PC (Camelot) que es un Ubuntu (bueno, en realidad un Linux Mint). El binario generado se ejecutará correctamente en máquinas con la misma distribución de liinux que la que usé para compilar el binario.

Pero veamos que pasa si ejecutamos nuestro binario en una distribución diferente:

(venv)dante@Camelot:~/Projects/cifra$ docker run -d -ti -v $(pwd)/build/x86_64-unknown-linux-gnu/debug/install/:/work centos
Unable to find image 'centos:latest' locally
latest: Pulling from library/centos
a1d0c7532777: Pull complete
Digest: sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177
Status: Downloaded newer image for centos:latest
376c6f0d085d9947453a2786a9a712146c471dfbeeb4c452280bd028a5f5126f
(venv) dante@Camelot:~/Projects/cifra$ docker attach 376
[root@376c6f0d085d /]# ls /work
cifra lib
[root@376c6f0d085d /]# /work/cifra --help
/work/cifra: /lib64/libm.so.6: version `GLIBC_2.29' not found (required by /work/cifra)
[root@376c6f0d085d /]# exit
exit

(venv)dante@Camelot:~/Projects$

Como puedes ver, nuestro binario falle en Centos. Eso ocurre porque las librerías no se llaman igual en las distintas distribuciones por lo que nuestro binario falla al cargar las dependencias que necesita para funcionar.

Para resolver esto necesitamos compilar un binario estáticamente enlazado para no tener dependencias externas en absoluto.

Para compilar ese tipo de binarios necesitamos actualizar el framework de Rust:


(venv)dante@Camelot:~/Projects/cifra$ rustup target add x86_64-unknown-linux-musl
info: downloading component 'rust-std' for 'x86_64-unknown-linux-musl'
info: installing component 'rust-std' for 'x86_64-unknown-linux-musl'
30.4 MiB / 30.4 MiB (100 %) 13.9 MiB/s in 2s ETA: 0s

(venv)dante@Camelot:~/Projects$

Se supone que para hacer que PyOxidizer compile un binario como ese debemos hacer:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build --target-triple x86_64-unknown-linux-musl
[...]
Processing greenlet-1.1.1.tar.gz
Writing /tmp/easy_install-futc9qp4/greenlet-1.1.1/setup.cfg
Running greenlet-1.1.1/setup.py -q bdist_egg --dist-dir /tmp/easy_install-futc9qp4/greenlet-1.1.1/egg-dist-tmp-zga041e7
no previously-included directories found matching 'docs/_build'
warning: no files found matching '*.py' under directory 'appveyor'
warning: no previously-included files matching '*.pyc' found anywhere in distribution
warning: no previously-included files matching '*.pyd' found anywhere in distribution
warning: no previously-included files matching '*.so' found anywhere in distribution
warning: no previously-included files matching '.coverage' found anywhere in distribution
error: Setup script exited with error: command 'musl-clang' failed: No such file or directory
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Custom { kind: Other, error: "command [\"/home/dante/.cache/pyoxidizer/python_distributions/python.70974f0c6874/python/install/bin/python3.9\", \"setup.py\", \"install\", \"--prefix\", \"/tmp/pyoxidizer-setup-py-installvKsIUS/install\", \"--no-compile\"] exited with code 1" }', pyoxidizer/src/py_packaging/packaging_tool.rs:336:38
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

(venv)dante@Camelot:~/Projects$

Como se puede ver, me sale un error que no he sido capaz de resolver. Debido a eso,  puse una incidencia en la página de GitHub de PyOxidizer. El autor de PyOxidizer me respondió amablemente que necesitaba el comando musl-clang en mi sistema. No he encontrado paquete alguno con el comando musl-clang por lo que supongo que es algo que hay que compilar desde el código fuente. He intentado encontrar en Google cómo hacerlo pero no he sido capaz (debo admitir que no soy un gurú de C/C++). Por eso, supongo que tendré que usar PyOxidizer sin la compilación estática hasta que la dependencia de musl-clang desaparezca o la documentación explique cómo conseguir ese comando.


Conclusión

Aunque no he conseguido crear binarios estáticamente enlazados, PyOxidizer parece una herramienta prometedora. Creo que puedo usarla con vdist para acelerar la velocidad del proceso de empaquetado. Como en vdist uso contenedores específicos para cada tipo de paquete la incapacidad para crear binarios estáticamente enlazados no parece ser bloqueante. Además, parece contar con un desarrollo activo, por lo que parece una buena oportunidad para ayudar a simplificar el empaquetado de aplicaciones python y su posterior distribución.