Usando PyInstaller y Cython para crear un ejecutable de Python
Has creado una aplicación Python y quieres distribuirla. Probablemente la tiene ejecutada en un Entorno Virtual Python . Pero los clientes no tienen esta configuración, algunos pueden incluso no tener Python instalado.
Hay varios programas que pueden convertir su aplicación Python en un solo archivo ejecutable. Aquí estoy usando PyInstaller. También es posible que quieras proteger parte de tu código y/o acelerar algunas operaciones. Para ello podemos utilizar Cython.
Spoiler: Añadiendo declaraciones de tipo estático Cython para dos variables, el rendimiento se multiplicó por 50.
Como siempre, mi máquina de desarrollo está ejecutando Ubuntu 20.04.
PyInstaller
PyInstaller es una herramienta para empaquetar tus archivos Python y todas sus dependencias en un único ejecutable o directorio. La aplicación empaquetada puede ejecutarse sin necesidad de instalar un intérprete o módulos Python .
Actualmente, se puede utilizar PyInstaller para crear ejecutables para Linux, Windows y MacOS. Para crear un ejecutable para Linux, debes ejecutar PyInstaller en un sistema Linux , para crear un ejecutable para Windows, necesitas ejecutar PyInstaller en un sistema Windows , etc. PyInstaller también se ejecuta en un Raspberry Pi.
PyInstaller utiliza archivos (de biblioteca) del sistema de destino, lo que significa que un ejecutable creado para Windows 10 puede no funcionar en Windows 8.
Actualmente, PyInstaller no es compatible con ARM. Sin embargo, es posible crear un gestor de arranque para ARM, ver 'Building the bootloader'. Una vez hecho esto, puedes ejecutar PyInstaller en tu sistema ARM para construir el ejecutable.
También es posible alimentar PyInstaller con algunos archivos (de biblioteca), y hacer una compilación cruzada, por ejemplo, utilizar un compilador ARM en su sistema de desarrollo, esto sería genial, pero no he investigado esto, ni hay mucho que encontrar al respecto en Internet.
El archivo ejecutable generado no es pequeño. El tamaño de un archivo ejecutable mínimo es de unos 7 MB. Siempre debes ejecutar PyInstaller en un Entorno Virtual donde tengas un mínimo de paquetes.
PyInstaller puede crear un único ejecutable o un directorio que contenga sus archivos ejecutables y de biblioteca. El enfoque del directorio es un poco desordenado, pero tiene la ventaja de que tu aplicación se inicia más rápido porque muchos archivos no tienen que ser descomprimidos. Si tienes más ejecutables que utilizan las mismas bibliotecas puedes agruparlos en este directorio.
Un ejecutable generado por PyInstaller puede activar el software antivirus. En este caso, debes ponerlo en la lista blanca.
Cython
Del sitio web: Cython es un compilador estático optimizador para el lenguaje de programación Python y el lenguaje de programación Cython extendido (basado en Pyrex). Hace que escribir extensiones en C para Python sea tan fácil como el propio Python '.
Tengo una aplicación Python y quiero distribuirla. Puedo hacerlo con PyInstaller, pero también quiero tener algunas partes de mi código Python protegidas. Si buscas en internet 'pyinstaller decompile' verás que no es tan difícil sacar el código, el bytecode de .pyc es fácil de descompilar.
Como ya necesitamos construir ejecutables con PyInstaller para diferentes sistemas de destino, podemos usar Cython para compilar algunos archivos Python antes de iniciar PyInstaller. En el caso de Linux, los archivos compilados son archivos .so , archivos de bibliotecas compartidas. Todo puede ser objeto de ingeniería inversa, pero .so es más difícil que .pyc.
Y ahora que usamos Cython, quizá también podamos mejorar fácilmente el rendimiento.
La aplicación de prueba
En un entorno virtual instalamos los paquetes PyInstaller y Cython:
- pip install pyinstaller
- pip install cython
Luego creamos un directorio 'project' con la siguiente estructura y archivos:
.
├── app
│ ├── factory.py
│ └── some_package
│ ├── __init__.py
│ └── some_classes.py
├── README
└── run.py
# run.py
from app.factory import run
if __name__ == '__main__':
run()
# app/factory.py
import datetime
from app.some_package import SomeClass1
def run():
print('Running ...')
some_class1 = SomeClass1()
ts = datetime.datetime.now()
loops = some_class1.process()
print('Ready in: {} seconds, loops = {}'.\
format((datetime.datetime.now() - ts).total_seconds(), loops))
# app/some_package/__init__.py
from .some_classes import SomeClass1
# app/some_package/some_classes.py
class SomeClass1:
def process(self):
print('Processing ...')
a = 'a string'
loops = 0
for i in range(10000000):
b = a
loops += 1
return loops
README es un archivo vacío. El método 'process' en SomeClass1 contiene un for loop haciendo una asignación e incrementando 10000000 veces. En factory.py medimos el tiempo que tarda en ejecutarse este método.
Ejecutar con Python
Para ejecutar esto con Python hacemos:
python run.py
El resultado es:
Running ...
Processing ...
Ready in: 0.325362 seconds, loops = 10000000
Esto significa que tarda 320 milisegundos en completarse.
Crear un ejecutable con PyInstaller
Para crear un ejecutable:
pyinstaller --onefile --name="myapp" --add-data="README:README" run.py
Esto da mucha salida:
23 INFO: PyInstaller: 4.5.1
23 INFO: Python: 3.8.10
29 INFO: Platform: Linux-5.11.0-37-generic-x86_64-with-glibc2.29
...
4996 INFO: Building EXE from EXE-00.toc completed successfully.
En el directorio 'project' se han añadido dos nuevos directorios
- build
- dist
Nuestro ejecutable está en el directorio dist , ocupa casi 7 MB:
-rwxr-xr-x 1 peter peter 6858088 okt 6 11:05 myapp
Para ejecutarlo:
dist/myapp
Y el resultado:
Running ...
Processing ...
Ready in: 0.422389 seconds, loops = 10000000
Es un 20% más lento que la versión no PyInstaller .
Ahora dejemos que PyInstaller cree un directorio con archivos, es decir, sin la opción --onefile :
pyinstaller --name="myapp" --add-data="README:README" run.py
Importante: El formato de --add-data es <SRC:DST>. El separador es ':' en Linux y ';' en Windows.
Nuestro ejecutable está en el directorio dist/myapp, para ejecutarlo:
dist/myapp/myapp
Y el resultado:
Running ...
Processing ...
Ready in: 0.423248 seconds, loops = 10000000
El tiempo de procesamiento es casi el mismo que con la opción --onefile .
Añadir Cython
Para empezar, sólo dejamos que Cython compile un archivo:
app/some_package/some_classes.py
Para ello creamos un fichero setup.py en el directorio 'project' :
# setup.py
from setuptools import find_packages, setup
from setuptools.extension import Extension
from Cython.Build import cythonize
from Cython.Distutils import build_ext
setup(
name="myapp",
version='0.100',
ext_modules = cythonize(
[
Extension("app.some_package.some_classes",
["app/some_package/some_classes.py"]),
],
build_dir="build_cythonize",
compiler_directives={
'language_level' : "3",
'always_allow_keywords': True,
}
),
cmdclass=dict(
build_ext=build_ext
),
)
Para compilar, ejecutamos:
python setup.py build_ext --inplace
Esto creó un directorio build_cythonize pero lo más importante es que creó un nuevo archivo en nuestro directorio de módulos:
└── some_package
├── __init__.py
├── some_classes.cpython-38-x86_64-linux-gnu.so
└── some_classes.py
El archivo 'some_classes.cpython-38-x86_64-linux-gnu.so' es la versión compilada de some_classes.py! Tenga en cuenta que el tamaño del archivo compilado es de 168 KB.
-rw-rw-r-- 1 peter peter 68 okt 6 10:31 __init__.py
-rwxrwxr-x 1 peter peter 168096 okt 6 12:48 some_classes.cpython-38-x86_64-linux-gnu.so
-rw-rw-r-- 1 peter peter 293 okt 6 12:39 some_classes.py
Lo bueno de Cython es que ahora podemos ejecutar nuestra aplicación de la misma manera sin cambiar nada:
python run.py
El resultado es:
Running ...
Processing ...
Ready in: 0.079608 seconds, loops = 10000000
Se ejecuta 4 veces más rápido que sin Cython!
Crear el ejecutable con PyInstaller usando el archivo compilado
Como debemos añadir más cosas creamos un script Bash compile_and_bundle que podemos ejecutar:
#!/usr/bin/bash
app_name="myapp"
echo "building for app = ${app_name}"
# cleanup
rm -R dist
rm -R build
rm -R "${app_name}.spec"
find . | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf
# compile
python setup.py build_ext --inplace
# bundle
pyinstaller \
--onefile \
--name "${app_name}" \
--add-data="README:README" \
--add-binary="app/some_package/some_classes.cpython-38-x86_64-linux-gnu.so:app/some_package/" \
run.py
Hacer el script ejecutable:
chmod 755 compile_and_bundle
Compilar y empaquetar:
./compile_and_bundle
De nuevo un montón de salida que termina con:
...
064 INFO: Building EXE from EXE-00.toc completed successfully.
Nuestro ejecutable está en el directorio dist . Vamos a ejecutarlo:
dist/myapp
El resultado es:
Running ...
Processing ...
Ready in: 0.128354 seconds, loops = 10000000
Sin cambiar nada hemos creado un ejecutable que se ejecuta de 3 a 4 veces más rápido que la versión sin Cython. También protege mejor nuestro código.
La versión Python de some_classes.py no se incluye en el ejecutable, sólo se añade la versión compilada. Al menos eso es lo que concluyo. Esto muestra un número de aciertos:
grep -r factory.py build
Esto no muestra nada:
grep -r some_classes.py build
Mucho más rápido con declaraciones de tipo estáticas Cython
En ciertos lugares de nuestro código podemos añadir declaraciones de tipo estático Cython . Para ello, primero debemos importar Cython. En some_classes.py añadimos declaraciones de tipo para 'i' y 'loops'.
# app/some_package/some_classes.py
import cython
class SomeClass1:
def process(self):
i: cython.int # here
loops: cython.int # and here
print('Processing ...')
a = 'a string'
loops = 0
for i in range(10000000):
b = a
loops += 1
return loops
Compilar y empaquetar:
./compile_and_bundle
Nuestro ejecutable está en el directorio dist . Vamos a ejecutarlo:
dist/myapp
El resultado es:
Running ...
Processing ...
Ready in: 0.002335 seconds, loops = 10000000
¡Increíble! Con un simple cambio, ¡nuestro programa se ejecuta 50 veces más rápido!
Compilando todos los archivos de un paquete
También quería compilar todos los archivos de mi paquete. Pero no pude conseguir que esto funcionara. Lo intenté en setup.py:
[
Extension("app.some_package.some_classes.*",
["app/some_package/some_classes/*.py"]),
],
El resultado fue:
ValueError: 'app/some_package/some_classes/*.py' doesn't match any files
La única forma en que pude conseguir que esto funcionara es creando un archivo casi idéntico compile.py en el directorio 'app' .
# app/compile.py
from setuptools import find_packages, setup
from setuptools.extension import Extension
from Cython.Build import cythonize
from Cython.Distutils import build_ext
setup(
name="packages",
version='0.100',
ext_modules=cythonize(
[
Extension('some_package.*', ['some_package/*.py']),
],
build_dir="build_cythonize",
compiler_directives={
'language_level' : "3",
'always_allow_keywords': True,
},
),
cmdclass=dict(
build_ext=build_ext
),
packages=['some_package']
)
y luego ejecutarlo en el directorio 'app' :
python compile.py build_ext --inplace
Ahora ambos archivos están compilados. Podemos añadirlos usando --add-binary como antes.
Resumen
Este ha sido un viaje increíble. Hemos aprendido a construir ejecutables dependientes de la plataforma. Y mientras tanto hemos aumentado el rendimiento de nuestro programa sin apenas esfuerzo.
Por supuesto, si tu programa es mayoritariamente IO-bound no notarás mucho cambio, pero para las tareas CPU-bound el aumento de velocidad puede ser muy significativo (o incluso puede ser esencial).
Enlaces / créditos
Compiling Python Code with Cython
https://ron.sh/compiling-python-code-with-cython
Cython
https://en.wikipedia.org/wiki/Cython
PyInstaller Manual
https://pyinstaller.readthedocs.io/en/stable/
Using Cython to protect a Python codebase
https://bucharjan.cz/blog/using-cython-to-protect-a-python-codebase.html
Using PyInstaller to Easily Distribute Python Applications
https://realpython.com/pyinstaller-python
Leer más
Compile Cython Distribution PyInstaller
Recientes
- Un conmutador de base de datos con HAProxy y el HAProxy Runtime API
- Docker Swarm rolling updates
- Cómo ocultar las claves primarias de la base de datos UUID de su aplicación web
- Don't Repeat Yourself (DRY) con Jinja2
- SQLAlchemy, PostgreSQL, número máximo de filas por user
- Mostrar los valores en filtros dinámicos SQLAlchemy
Más vistos
- Usando Python's pyOpenSSL para verificar los certificados SSL descargados de un host
- Usando PyInstaller y Cython para crear un ejecutable de Python
- Reducir los tiempos de respuesta de las páginas de un sitio Flask SQLAlchemy web
- Conectarse a un servicio en un host Docker desde un contenedor Docker
- SQLAlchemy: Uso de Cascade Deletes para eliminar objetos relacionados
- Usando UUIDs en lugar de Integer Autoincrement Primary Keys con SQLAlchemy y MariaDb