angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Usando PyInstaller y Cython para crear un ejecutable de Python

6 octubre 2021 Actualizado 6 octubre 2021
post main image
https://www.pexels.com/nl-nl/@polina-kovaleva

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

Deje un comentario

Comente de forma anónima o inicie sesión para comentar.

Comentarios

Deje una respuesta.

Responda de forma anónima o inicie sesión para responder.