Mostrando entradas con la etiqueta python. Mostrar todas las entradas
Mostrando entradas con la etiqueta python. Mostrar todas las entradas

30 octubre 2023

Robust Python: Write Clean and Maintainable Code by Patrick Viafore


Cuando no te ganas la vida con la programación sino que simplemente eres un aficionado al que le gusta programar, lo difícil no es aprender múltiples lenguajes sino mantenerlos vivos sin que se te olviden. Cuando sólo puedes dedicar un rato al día, te centras en un proyecto con un determinado lenguaje y el resto de ellos se van oxidando. Es cierto que hay libros con los que repasar, pero con el tiempo superas el nivel básico y ya no te vale cualquier libro para mantener fresco un lenguaje. Yo me temo que he llegado a ese punto con Python y andaba un poco desesperado por encontrar un libro que realmente me aportase algo nuevo. Hasta que tuve la fortuna de adquirir este libro en un pack (bendito Humble Bundle).

"Robust Python: Write Clean and Maintainable Code" de Patrick Viafore no es un libro para novatos, ni siquiera para desarrolladores intermedios, sino más bien para desarrolladores experimentados que son los que más apreciarán las propuestas de este libro para desarrollar aplicaciones complejas que puedan ser mantenibles con el paso del tiempo.

El libro empieza explicando el sistema de tipado opcional de Python y lo lleva mucho más lejos que los tutoriales de Internet hasta un nivel de sofisticación nada habitual. ¿Sabías que puedes definir interfaces en Python? ¿sabías que puedes definir tus propios tipos? Dataclasses, Enums y otros tipos se exponen aquí con funcionalidades interesantísimas que ni se vislumbran en los textos introductorios.

Después de eso, se profundiza en algunos de los principios SOLID y en arquitecturas, como la de eventos, para cumplirlos. Todo ello, siguiendo unos ejemplos sencillos y amenos de entender.

Finalmente se trata extensamente, el ecosistema de Python para hacer pruebas automatizadas, explicando múltiples estrategias.

El libro me ha gustado mucho. Va al grano, es ameno y realmente aporta herramientas nuevas que no suelen aparecer en el resto de libros de este nivel. En resumen: una muy buena compra si lo que quieres es pulir tu Python para empezar a ser experto.

06 abril 2023

Cómo crear tus propias Actions para GitHub Actions

En  mi artículo sobre GitHub Actions vimos que se pueden crear flujos de trabajo completo simplemente usando unidades ya hechas, denominadas Actions, que se pueden encontrar en el GitHub Marketplace

En ese marketplace puedes encontrar muchísimas Actions listas para ser usadas, pero probablemente en algún momento te veas en la necesidad de hacer algo para lo que no haya aún Action alguna en el marketplace. En esos caso podrías usar tus propios scripts. De hecho, en aquel artículo ya utilicé un script (./ci_scripts/get_version.py) en la sección "Compartir datos entre pasos y flujos". El problema con ese enfoque es que si quieres hacer las mismas tareas en otro proyecto tendrás que copiar los scripts entre proyectos y adaptarlo. Lo suyo en realidad es transformar los scripts en Actios que puedan ser fácilmente reutilizables no sólo en tus proyectos sino también en los de otros desarrolladores.

Todas las Actions que se pueden encontrar en el marketplace se han desarrollado usando uno de los métodos que voy a explicar aquí. En realidad, si entrar en la página del marketplace de cualquier Action encontrarás un enlace, a mano derecha, al repositorio de git de esa Action, por lo que puedes analizar su código y ver cómo funciona.

Hay tres métodos principales para crear tu propio Action para GitHub:

  • Composite Actions: Son las más sencillas y rápidas de desarrollar, pero tienen que cumplir la condición de que se basen en un script autosuficiente que no requiera que se instalen dependencias adicionales. Es decir, deben ser capaces de funcionar con lo que un Linux estándar ofrezca de base.
  • Docker Actions: Si tu Action va a necesitar la instalación de alguna dependencia para funcionar, entonces tendrás que usar esta opción.
  • Javascript Actions: Bueno... es cierto que también puedes desarrollar tus Actions usando javascript, pero aborrezco ese lenguaje, así que no voy a desarrollar esta opción en este artículo.
El problema con las dependencias de las Actions es que pueden contaminar el entorno donde se vaya a usar la Action. Si no tienes cuidado, y optas por una Composite Action instalando dependencias, estas pueden colisionar con la de la app que quieras compilar con la Action. Esa es la razón por la que resulta imprescindible encapsular nuestra Action y sus dependencias para que sean independientes del entorno en el que compilemos la app. Por supuesto, esto no será un problema si el propósito explícito de la Action es precisamente instalar algo para preparar el entorno. Hay Actions, por ejemplo, para instalar y configurar Pandoc para usarlo en el workflow. El problema surge cuando se supone que el propósito explícito de tu Action es algo ajeno a la instalación de paquete alguno (por ejemplo, copiando ficheros) y sin embargo acaba instalando algo de manera inadvertida comprometiendo el entorno del flujo de trabajo. Por eso, lo mejor es que cuando necesites instalar algo para tu Action lo hagas en un contenedor de Docker y hagas que el script de tu Action se ejecute en ese contenedor de Docker, de manera que sus dependencias sean completamente independientes de los paquetes instalados en el entorno del flujo.  

Composite Actions

Si a tu Action le basta con un puñado de comandos de Bash o con un script de Python que utilice exclusivamente la librería estándar, entonces las Composite Actions son tu opción.

Como ejemplo de una Composite Action, vamos a revisar cómo funciona mi Action rust-app-version. Esa Action busca un fichero de configuración Cargo.toml y lee qué versión consta en él para la aplicación de Rust. La cadena de esa versión es precisamente la salida del Action, de manera que puedas usar esa cadena en otros puntos de tu flujo, por ejemplo para etiquetar una nueva release en GitHub. Esta Action en concreto sólo usa módulos disponibles en la distribución estándar de Python. Es decir, que no tiene que instalar nada en la máquina virtual de GitHub Actions (el runner). Es cierto que si analizas el repositorio de rust-app-version verás un fichero requierements.txt pero este sólo es para instalar paquetes necesarios para el testeo unitario de la Action (recuerda que una Action no deja de ser una app más).

Para tener tu propia Action, lo primero es crear un repositorio de GitHub para albergarlo. Allí podrás ubicar los poquitos ficheros que se necesitan en realidad para hacer funcionar tu Action.

En la raíz del repositorio debes crear un fichero que se llame "action.yml". Este fichero es realmente importante ya que es el que le da forma a tu Action. Debes conseguir que tus usuarios puedan pensar en tu Action como si fuera una caja negra, como si fuera un módulo en el que metes entradas (inputs) y recibes salidas (outputs). Precisamente esos inputs y outputs se definen en el fichero action.yml.

Si lees el fichero action.yml file de rust-app-version verás que esa Action sólo necesita un input denominado "cargo_toml_folder" y que en realidad el input es opcional, ya que si lo dejamos vacío asumirá el valor de ".":



Los outputs son algo diferentes, dado a que deben referirse al output de un caso específico de tu Action:


En la última sección especificamos que esta Action tendrá un único output llamado "app_version" y que ese output será el output llamado "version" de un paso con el valor de id "get-version".

Esos inputs y outputs definen lo que tu Action consume y ofrece, es decir, lo que tu Action hace. El cómo lo hace tu Action se define bajo la etiqueta "runs:". Ahí es donde defines que tu Action es una composite, en la que vas a llamar a una secuencia de pasos. Este ejemplo en particular sólo tiene un paso, pero puedes tener todos los que necesites:



Fíjate que en la línea 22 es donde el paso recibe el id "get-version". Ese nombre es importante para poder referirse a este paso en particular desde la configuración de los outputs.

La línea 24 es donde se ejecuta el comando. Aquí sólo he ejecutado un comando, pero si necesitases ejecutar varios comandos dentro del mismo paso bastaría con usar una barra después de la etiqueta run: "run: |". De esa manera, señalas que las siguientes líneas, que se indenten bajo la etiqueta "run:", son líneas separadas de comandos que deben ser ejecutadas secuencialmente.

El comando de la línea 24 también es interesante por 3 cosas:
  • Llama a un script situado en el repositorio de nuestro Action. Para referirse a la raíz del repositorio del Action hay que usar la variable de entorno github.action_path. Lo bueno es que aunque nuestro script esté en el repositorio del Action GitHub lo ejecuta de manera que tenga visibilidad del repositorio del flujo de trabajo desde el que se llama a la Action. Nuestro script (el del Action) verá los ficheros del repositorio del flujo como si hubiese sido ejecutado desde su raíz.
  • Al final de la línea se puede ver cómo se pueden utilizar los inputs, a través del contexto inputs.
  • Lo más raro de esa línea es probablemente como configuras el output del paso. Para fijar el output de un paso de bash debes hacer echo "::set-output name=<ouput_name>::<output_value>". En este caso, name es version y su valor es lo que  get_version.py imprime en la consola. Ten en cuenta que output_name se usa para recuperar el output, una vez que el paso finaliza, mediante ${{ steps.<id>.outputs.<output_name> }}, en este caso ${{ steps.get_version.outputs.version }}
Aparte de eso, no hay más que configurar los metadatos del Action. Para eso, no se necesitan más que unas pocas líneas:


Ten en cuenta que el valor de "name:" es el nombre que tendrá tu Action en el marketplace. El otro parámetro "description:" es la explicación corta que se mostrará junto al nombre en los resultados de las búsquedas del marketplace. Por último, la etiqueta "branding:" sirve para configurar el icono (de los disponibles en la suite Feather icon) y el color de este que representarán a nuestra Action en el marketplace.

Con esas 24 líneas en el fichero action.yml y tu script en su ruta respectiva ( en este caso la subcarpeta 
 rust_app_version/ ), ya puedes usar tu Action. Sólo necesitas pulsar el botón que aparecerá en tu repositorio para publicar el Action en el marketplace. De todos modos, antes de lanzarte a hacerlo te recomiendo que te leas este artículo hasta el final porque tengo algunos consejos que sospecho que te serán útiles.

Una vez publicada, tu Action ya será visible para el resto de los usuarios de GitHub. Además, se creará automáticamente la página de tu Action en el marketplace. Para usar una Action como esta lo único que tienes que hacer es incluir en tu flujo una configuración como la siguiente:



Docker actions

Si tu Action necesitase instalar alguna dependencia entonces deberías empaquetarla dentro de un contenedor de docker. De esa manera las dependencias de la Action no se mezclarán con las dependencias del flujo desde el que se llame a la Action.

Como ejemplo de una Docker Action, vamos a revisar cómo funciona mi Action markdown2man. Esta Action coge un fichero README.md y lo convierte en un manpage. Con esta Action no necesitas mantener dos fuentes diferentes para la documentación de cómo usar tu aplicación de consola. En vez de eso, puedes usar un único fichero README.md para documentar el uso de tu aplicación y generar automáticamente a partir de él el manpage.

Para hacer esa conversión markdwon2man necesita que se instale el paquete Pandoc. Además, Pandoc tiene también sus respectivas dependencias por lo que instalar todo eso en el runner del usuario puede romper su flujo de trabajo. En vez de eso, lo que haremos será instalar esas dependencias en una imagen de docker y ejecutar nuestro script desde esa imagen. Recuerda que docker te permite ejecutar scripts desde el contenedor interactuando con los ficheros del anfitrión del contenedor.

Igual que en el caso de las Composite Action, tendremos que crear un fichero action.yml en la raiz del repositorio del Action. Allí configuraremos nuestros metadatos, los inputs y los outputs tal y como hicimos para las Composite Actions. La diferencia en este caso es que concretamente markdown2man Action no emite output alguno, por lo que esa sección se omite. La sección para "runs:" también es diferente:


En esa sección definimos que esta Action es del tipo docker (en "using:"). Hay dos maneras de usar una imagen de docker en tu Action: 
  • Generar una imagen específicamente para esa Action y guardarla en el registro de docker de GitHub. En ese caso usaríamos la etiqueta "image: Dockerfile".
  • Usar una imagen del registro de DockerHub registry. Para hacer eso hay que usar la etiqueta "image: <dockerhub_user>:<docker-image-tag>".
Si la imagen que vamos a construir estuviera pensada para ser usada sólo en una Action de GitHub yo iría por el primer enfoque (el del Dockerfile). En el caso de markdwon2man elegí la opción del Dockerfile. Cada vez que actualicemos nuestro Dockerfile, GitHub se encargará de de construir una nueva imagen y guardarla en su registro para poder ofrecerla rápidamente a los Actions que hagan uso de esa imagen. Recuerda que un Dockerfile es una especie de receta para "cocinar" una imagen, por lo que los comandos que se incluyan en él solo se ejecutarán cuando se construya la imagen (cuando se "cocine"). Una vez construida la imagen, el único comando que se ejecutará es el que fijemos en la etiqueta "entrypoint" al llamar al contenedor (y pasarle argumentos) con el comando "docker run".

La etiqueta "args:" define los parámetros que se pasarán al script de nuestro contenedor. Probablemente uses alguno de los input de tu Action para pasárselo como parámetro al script del contenedor. Ten en cuenta que, como pasaba con las Composite Actions, los ficheros del repositorio del usuario que llama al Action también son visibles para el contenedor de dicho Action.

Al tener que definir un Dockerfile, las Docker Actions pueden parecer más complejas que las Composite Actions. Sin embargo, el fichero Dockerfile de markdown2man es bastante sencillo. Como nuestro script markdown2man está escrito en python, hice que la imagen se basase en la imagen de docker oficial para python 3.8:



Después, fijé los metadatos de la imagen:


Para configurar tu image, por ejemplo para instalar cosas, puedes usar los comandos RUN:


El comando ENV genera variables de entorno que pueden ser utilizados en otros comandos del Dockerfile:


Puedes usar el comando COPY para introducir ficheros de tu repositorio en la imagen. En este caso lo utilicé para copiar el fichero requirements.txt desde el repositorio a dentro de la imagen. Tus scripts se pueden incluir dentro de imagen, desde el repositorio del Action, de la misma manera:


Una vez que copié el script dentro de la imagen, lo hice ejecutable y lo enlacé desde la carpeta /usr/bin para incluirlo en el path del sistema:


Después de eso, se configura el script como el entrypoint de la imagen, de manera que se ejecute en cuanto se arranque la imagen con los argumentos mencionados en la etiqueta "args:" del fichero action.yml.



Puedes probar esa imagen en tu ordenador construyendo la imagen desde el Dockerfile y ejecutándola como un contenedor:

dante@Camelot:~/$ docker run -ti -v ~/your_project/:/work/ dantesignal31/markdown2man:latest /work/README.md mancifra


dante@Camelot:~$

Si el script del contenedor procesa ficheros del repositorio, nos vendrá bien probarlo localmente montando una carpeta de nuestro ordenador con el flag -v. Los dos últimos argumentos del ejemplo  (work/README.md y mancifra) son los argumentos que se le pasan al entrypoint.

Y eso es todo. Una vez que lo hayas probado todo ya puedes publicar tu Action y usarla en tus flujos:


Con una llamada como esa, se creará un fichero man llamado cifra.2.gz en una carpeta llamada man. Si la carpeta manpage_folder no existiese, entonces markdown2man la crearía para ti.

Tus Action son código de primera clase

Aunque tus Actions probablemente sean pequeñas, deberías tratarlas como lo harías con una app completa. Ten en cuenta que mucha gente encontrará tus Action a través del marketplace y los incluirá en sus flujos. Un error en tu Action puede romper los flujos de mucha gente, por eso hay que ser diligente y probar tu Action como harías con cualquier otra app.

Por eso, con mis Actions sigo la misma aproximación que en otros proyectos y configuro un flujo de GitHub para ejecutar tests contra cualquier push en la rama staging del repositorio de mi Action. Solo cuando los tests se ejecutan con éxito, se produce el merge automático de la rama staging con la principal y se genera una nueva release de la Action.

Vamos a usar el flujo de markdown2man como ejemplo. En él puedes ver que hay dos tipos de tests:

  • Tests unitarios: Comprueban el script de python en el que se basa markdown2man.

  • Tests de integración: Comprueban el comportamiento de markdown2man como Action. Aunque tu Action no se haya publicado aún, la puedes instalar desde un flujo del mismo repositorio (como puedes ver en las líneas 42 a 48). Así que lo que yo hago es llamar al Action precisamente desde la rama staging que estoy probando y uso ese Action con un fichero markdown que tengo preparado en la carpeta de test. si se genera un fichero manpage, entonces el test funcional tiene éxito (línea 53). Tener la oportunidad de probar tu Action contra tu propio repositorio está muy bien, ya que te permite comprobar cómo utilizaría la gente tu Action, sin necesidad de publicarlo.

Además de probarlo, deberías escribir un fichero README.md para tu Action, con el fin de explicar con detalle como usarlo. En ese documento deberían incluir al menos la siguiente información: 
  • Una descripción de los que hace el Action.
  • Los input y outputs obligatorios.
  • Los inputs y outputs opcionales.
  • Cualquier contraseña que el Action pudiera necesitar que se le facilite.
  • Que variables de entorno utiliza el Action.
  • Un ejemplo de cómo usar tu Action en un flujo.
Por último, yo añadiría también un fichero LICENSE, explicando los términos legales de uso de tu Action.

Conclusión

El punto fuerte de GitHub Actions es el alto grado de reusabilidad y compartición que promueve. Cada vez que te encuentres repitiendo el mismo conjunto de comandos, será el momento de hacer un Action con esos comandos y compartirlos a través del Marketplace. Si lo haces así obtendrás una pieza de funcionalidad que será mucho más fácil de usar en tus flujos que tirando de copia-pegas de montones de comandos y además estarás contribuyendo a mejorar el marketplace permitiendo que otros se puedan beneficiar del Action que hayas desarrollado.

Gracias a esa filosofía, el GitHub Marketplace ha crecido hasta hospedar una cantidad enorme de Actions, listos para ser usados y para salvarte de implementar una y otra vez la misma funcionalidad por ti mismo.

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.

12 octubre 2021

Cómo procesar los comandos de consola de tus aplicaciones Python usando Argparse

Todas las aplicaciones de consola tienen algo en común: tienen que procesar los argumentos que introduce el usuario al teclear el comando que lanza la aplicación. Pocas aplicaciones de consola carecewn de argumentos, la mayoría los necesitan para funcionar adecuadamente. Piensa por ejemplo en el comando ping: necesita al menos un argumento, la dirección IP o la URL a la que se le hace ping.

dante@Camelot:~/$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=113 time=3.73 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=113 time=3.83 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=113 time=3.92 ms
^C
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 3.733/3.828/3.920/0.076 ms

dante@Camelot:~$

Cuando ejecutas una aplicación, te llegan en la lista sys.argv todos los argumentos que usuario introdujo cuando llamó a tu aplicación desde la consola. Ese comando y sus argumentos se divide  en tokens, usando los espacios en blanco como separadores, siendo cada token un elemento de la lista sys.argv. El primer elemento de esa lista es el nombre de tu aplicación (el comando en si). Por eso, si por ejemplo hemos implementado nuestra propia versión del comando ping, el elemento 0 de sys.argv será "ping" y el elemento en la posición 1 será la IP, por ejemplo "8.8.8.8".

Podrías hacer tu propio análisis de los argumentos revisando por ti mismo el contenido de sys.argv. La mayor parte de los desarrolladores lo hacen así... al principio. Si lo haces así, pronto acabas dándote cuenta de que no es tan fácil gestionar de manera limpia los argumentos de usuario y encima acabas repitiendo muchísimo código en tus diferentes aplicaciones, por eso hay librerías pensadas para analizar por ti los argumentos de usuario. Una que me encanta es Argparse.

Argparse es un módulo incluido de serie en la distribución estándar de Python, así que no hay que descargarlo de Pypi. Una vez que se comprenden sus conceptos resulta fácil, muy flexible y gestiona por ti muchas casuísticas nada evidentes a priori. Es uno de esos módulos que echas de menos cuando programas en otros lenguajes.

El primer concepto a comprender de Argparser es precisamente el de Argument Parser. Para este módulo, un Argument Parser es una palabra fija que está seguida por argumentos de usuario posicionales u opcionales. El Argument Parser por defecto es precisamente el nombre de la aplicación que ejerce de comando. En nuestro ejemplo, el comando "ping" es un Argument Parser. Cada Argument Parser puede verse acompañado de Arguments, los cuales pueden ser de dos tipos:

  • Argumentos posicionales: Son aquellos obligatorios para el usuario. El usuario tiene que introducirlos o el comando asume que la orden es incorrecta y aborta la ejecución. Este tipo de argumentos tiene que introducirse en un orden específico. En nuestro ejemplo del comando ping "8.8.8.8" es un argumento posicional. Podemos encontrar otros ejemplos, el comando "cp" por ejemplo necesita dos argumentos posicionales: el fichero origen de la copia y el fichero de destino.
  • Argumentos opcionales: Pueden ser introducidos o no. Se marcan con etiquetas. Las etiqueta sabreviadas usan un guión y un carácter, mientras que las etiquetas largas usan un guión doble y una palabra. Algunos argumentos opcionales admiten un valor y otros no (son booleanos, true si se usan o false si no).
Aquí se pueden ver algunos de los argumentos que acepta el comando "cat":

dante@Camelot:~/$ cat --help
Usage: cat [OPTION]... [FILE]...
Concatenate FILE(s) to standard output.

With no FILE, or when FILE is -, read standard input.

-A, --show-all equivalent to -vET
-b, --number-nonblank number nonempty output lines, overrides -n
-e equivalent to -vE
-E, --show-ends display $ at end of each line
-n, --number number all output lines
-s, --squeeze-blank suppress repeated empty output lines
-t equivalent to -vT
-T, --show-tabs display TAB characters as ^I
-u (ignored)
-v, --show-nonprinting use ^ and M- notation, except for LFD and TAB
--help display this help and exit
--version output version information and exit

Examples:
cat f - g Output f's contents, then standard input, then g's contents.
cat Copy standard input to standard output.

GNU coreutils online help: <https://www.gnu.org/software/coreutils/>
Full documentation at: <https://www.gnu.org/software/coreutils/cat>
or available locally via: info '(coreutils) cat invocation'

dante@Camelot:~$

Hay comandos más complejos que incluyen lo que se denomina Subparsers. Los subaparsers aparecen cuando nuestro comando incluye otras palabras "fijas", como verbos. Por ejemplo, el comando "docker" tiene muchos subparser: "docker build", "docker run" o "docker ps". Cada subparser puede aceptar su propio conjunto de argumentos. Un subparser puede incluir a otros subparsers, formando subparser anidados y dando lugar a árboles de comandos.

Para mostrar como usar el módulo argparse, voy a usar como ejemplo my propia configuración para la aplicación Cifra. En concreto usaremos su fichero cifra_launcher.py file en GitHub.

En su sección __main__ se puede ver cómo se procesan los argumentos a alto nivel:

Como se puede ver esatamos usando sys.argv para obtener los argumentos facilitados por el usuario, descartando el primero ("[1:]") porque es el comando raiz mismo: "cifra".

Me gusta usar sys.argv como parámetro por defecto de main() porque así puedo llamar a main() desde mis test funcionales, usando una lista específica de argumentos.

Los argumentos se pasan a la función parse_arguments() que es la función que hace toda la magia de argparse. Allí se puede ver la configuración base de argparse:

Ahí se puede configurar el parser base, el que se enlaza al comando raiz, definiendo la descripción del comando y una nota final ("epilog") para el comando. Esos textos aparecerán cuando el usuario llame al comando con el argumento "--help".

Mi aplicación cifra tiene algunos subparsers, al igual que hace docker. Para permitir que un parser albergue subparsers, hay que hacer:

Una vez que has permitido que tu parser tenga subparsers, ya se puede empezar a crear esos subparser. Cifra tiene 4 subparsers en ese nivel: 

 

 

Luego veremos que argparse devuelve un objeto similar a un diccionario tras realizar su labor de parseo. Con el código anterior, si por ejemplo el usuario introdujo "cifra dictionary" entonces el objeto similar a un diccionario tendrá una clave "mode" con un valor "dictionary".

Cada subparser puede tener a su vez subparsers, en realidad en nuestro código de ejemplo el subparser "dictionary" tiene más subparsers que añaden más ramas al árbol del comando. De esa manera se puede llamar a "cifra dictionary create", "cifra dictionary update", "cifra ditionary delete", etc.

Una vez que llegas a la conclusión de que una rama ha llegado a su máxima profundidad y no necesita más subparsers, se pueden añadir sus respectivos argumentos. Para el subparser de "cipher" tenemos los siguientes argumentos:


En estos argumentos, "algorithm" y "key" son posicionales y por lo tanto obligatorios. Los argumentos que se sitúen en esas posiciones serán los valores de las claves "algorithm" y "key" del objeto devuelto por argparse.

El parámetro metavar es la cadena que queremos que se use para representar este parámetro cuando se llame a la ayuda con el argumento --help. Como te puedes imaginar, el parámetro help es la cadena de ayuda que sale para explicar este argumento cuando se llama a la ayuda con --help.

El parámetro type se usa para preprocesar el argumento facilitado por el susuario. Por defecto, los argumentos se interpretan como strings, pero si se usa type=int entonces se convertirán a int (o se lanzaría un error si la conversión resultase imposible). En realidad str o int son funciones, así que se pueden pasar los nombres de tus propias funciones de conversión a type. Por ejemplo, para el parámetro file_to_cipher yo he usado mi propia función _check_is_file().

Como se puede ver, esta función comprueba si los argumentos facilitados se corresponde con una ruta de ficheros correcta y de ser así devuelve la cadena de la ruta o un error si la ruta fuese incorrecta.

Recuerda que los parámetros opcionales siempre vienen precedidos de guiones. Si esos parámetros están seguidos de un argumento dado por el usuario, ese argumento será el valor para la clave, denominada como el nombre largo del parámetro, en el objeto tipo diccionario devuelto por argparser. En nuestro ejemplo, la línea 175 significa que cuando el usuario teclea "--ciphered_file myfile.txt", argparser devolverá una clave llamada "ciphered_file" con el valor  "myfile.txt".

A veces los parámetros opcionales no necesitarán valores. Pr ejemplo, podrían ser simples parámetros booleanos para activar una salida detallada:

parser.add_argument("--verbose", help="increase output verbosity", action="store_true", default=False)

Con action="store_true" el objeto tipo diccionario tendrá la clave "verbose" asociada con un valor booleano: verdadero si se usó --verbose o falso en caso contrario.

Antes de devolver de salir de parse_arguments() me gusta filtrar el contenido del diccionario a devolver:


En esta sección de código parsed_arguments() es el objeto tipo diccionario que devuelve argparse tras procesar los argumentos del usuario con arg_parser.parse_args(args). Este objeto incluye también, con valor None, todos los argumentos opcionales que se podrían haber introducido pero que al final no se metieron. Podría devolver directamente este objeto y luego ir comprobando parámetro a parámetro si es None o no, pero me parece más limpio quitar todas las claves que sean None y luego limitarme a comprbar si cada clave está presente o no. Por eso filtro el objeto diccionario en la línea 235.

De vuelta a la función main(), tras procesar los argumentos del usuario se puede desarrollar el resto de la lógica de la aplicación dependiendo del contenido del diccionario de argumentos. Por ejemplo, para cifra:

 De esta manera, argparser te permite procesar los argumentos del usuario de una manera limpia y que te ofrece también por el mismo "precio" (gratis) mensajes de ayuda y de error, dependiendo de si el usuario llamó a --help o introdujo un comando erroneo o equivocado.En realidad, los mensajes generados son tan limpios que los uso para el redme.md de mi repositorio y conforma el punto de partida para generar mis páginas de man (aunque eso ya es material para otro artículo).

18 junio 2017

Empaquetando aplicaciones Python - Paquetes DEB (y RPM)

La manera nativa de distribuir código Python es a través de PyPI, como expliqué en un artículo previo. Pero para ser sinceros PyPI tiene varias pegas que limitan su uso a entornos de desarrollo.

El problema con PyPI es que no implementa una gestión de paquetes completa de tal manera que la desinstalación de paquetes de pip deja que desear a menudo y no hay manera de revertir una desinstallación con errores. Además, los procedimientos de instalación de pip a menudo compilan paquetes de código fuente lo que puede ser terriblemente lento para un virtualenv completo.

Desplegar aplicaciones Python debería ser rápido y debería permitir hacerlo de manera que, llegado el caso, las desinstalaciones fuese siempre límpias.

Debian tiene una gestión de paquetes de sistema realmente sólida y estable. Puede chequear dependencias tanto al instalar como al desinstalar, así como ejecutar scripts previos y posteriores a la instalación. Esa es la razón por la que es uno de los sistemas de paquetería más usados en el ecosistema Linux.

Este artículo va repasar maneras para empaquetar aplicaciones Python en paquetes Debian que puedan ser fácilmente instalados en distribuciones Debian/Ubuntu.

El formato de paquetes de Debian

Aunque otros sistemas de paquetes de Linux se basan en formatos binarios, los paquetes Debian son simplemente ficheros de tipo ar que contienen un par de ficheros empaquetados mediante tar y comprimidos mediante gzip o bzip. Unos de estos ficheros contiene el árbol de ficheros con los ficheros de nuestra aplicación y el otro contiene los ficheros de configuración del paquete.

Los ficheros de configuración son meros ficheros de texto, por lo que la manera clásica de crear paquetes Debian implica sólo crar carpetas, editar unos ficheros de texto y ejecutar un comando que, en su forma más simple es:

dante@Camelot:~/project-directory$ debuild -us -uc


Se puede encontrar un buen tutorial sobre el tema aquí.

El tema no es complicado pero puede tener su intríngulis y requiere crear y editar muchos ficheros de texto. Por eso, no resulta sorprendente ver como los desarrolladores han crado muchas herramientas para automatizar esta tarea. Vamos a ver algunas de estas herremientas especializadas en el empaquetado de aplicaciones Python en paquetes  Debian.


STDEB

Si ya estás acostumbrado al empaquetado para PyPI, no debería resultarte complicado coger los conceptos de stdeb. Si no sabes a qué me refiero deberías echarle un ojo al artículo de este blog al que enlazaba al comienzo de este.

Dado que stdeb llama a algunas herramientas nativas de Debian/Ubuntu, es necesario usarlo desde una de estas distribuciones. Si estás en un Debian/Ubuntu, se puede instalar stdeb desde los repositorios estándar del sistema:

dante@Camelot:~$ sudo aptitude search stdeb
p   python-stdeb    - Python to Debian source package conversion utility                                 p   python3-stdeb   - Python to Debian source package conversion plugins for distutils                  
dante@Camelot:~$ sudo aptitude install python-stdeb python3-stdeb


También se puede instalar stdeb desde PyPI, como paquete python, lo que tiene la ventaja de que así se puede instalar una versión más reciente.

El mejor flujo de trabajo para usar stdeb implica crear los mismos ficheros que se usarían para crear un paquete para PyPI, de esta manera stdeb puede utilizar el fichero setup.py para conseguir la información necesaria para crear los fichero de configuración de un paquete debian.

Por eso, supongamos que nuestra aplicación estuviese lista para ser empaquetada para PyPI, con un setup.py ya creado. Para este ejemplo he clonado el siguiente repositorio de git.

Para hacer que stdeb genere un paquete fuente de debian sólo hay que hacer lo siguiente:

dante@Camelot:~/geolocate$ python3 setup.py --command-packages=stdeb.command sdist_dsc

La parte de --command-packages puede ser un poco complicada pero el comando no puede funcionar sin ella, por eso confía en mi e inclúyelo.

La ejecución de este comando tiene una salida de texto bastante profusa pero al final te darás cuenta que se habrán creado algunas carpetas en nuestro directorio de trabajo. Tienes que fijarte en una carpeta llamada deb_dist. Esa carpeta contiene principalmente tres ficheros: un fichero .dsc, un fichero .orig.tar.gz y un fichero .diff.gz. Esos tres ficheros juntos son lo que denominamos un paquete fuente.

Dentro de la carpeta deb_dist encontrarás otra llamada como tu proyecto pero con la versión principal de tu aplicación añadida al final. Esa carpeta contiene todos los datos generados para compilar un paquete debian binario. Por eso, entra en dicha carpeta y ejecuta lo siguiente:

dante@Camelot:~/geolocate/deb_dist/glocate-1.3-0$ dpkg-buildpackage -rfakeroot -uc -us

La parte de fakeroot es sólo un flag para poder construir un paquete debian sin estar logado como root. Ese es el comando recomendado en la documentación, pero por las pruebas que he realizado también se puede usar el comando debuild visto antes:

dante@Camelot:~/geolocate/deb_dist/glocate-1.3-0$ debuild -uc -us

Al final debuild no es más que un wrapper para dpkg-buildpackage que automatiza algunas cosas que de otra manera tendrían que hacerse manualmente.

Después de cualquiera de esos comandos, deberías encontrar un paquete fuente de debian en la carpeta deb_dist:

dante@Camelot:~/geolocate/deb_dist/glocate-1.3-0$ ls *.deb python3-glocate_1.3.0-1_all.deb

Lo anterior es el "método en dos pasos", aunque puedes reducirlo a un único paso haciendo:

dante@Camelot:~/geolocate$ python3 setup.py --command-packages=stdeb.command bdist_deb

Aunque ambos métodos acaban con un paquete .deb en la carpeta debian_dist, deberías ser consciente de que el paquete debian generado depende de la arquitectura por mucho que su nombre incluya la coletilla "_all". Eso significa que si generas en paquete en un Ubuntu de arquitectura amd64 lo más probable es que tengas problemas si intentas instalar dicho paquete en un Ubuntu con otra arquitectura. Para evitar este problema, hay dos opciones:


  • Usar máquinas virtuales para compilar un paquete debian para cada arquitectura objetivo.
  • Usar un repositorio PPA: Ubuntu ofrece espacio personal para alojar proyectos de empaquetado. Se sube un paquete fuente (el contenido de la carpeta deb_dist tras ejecutar "python3 setup.py --command-packages=stdeb.command sdist_dsc"), y el servidor lo compila a cada arquitectura objetivo (principalmente x86 y amd64). Tras la compilación, los paquetes creados aparecen en el PPA personal hasta que los borras o los reemplazas con versiones más nuevas. 
Si tus aplicaciones python sólo usan los paquetes que vienen por defecto con python entonces el empaquetado acaba aquí, pero si usas paquetes adicionales, por ejemplo descargados desde PyPI, lo más probable es que el paquete generado no los incluya correctamente.

Continuando con nuestro ejemplo, si miras en el fichero setup.py de geolocate podrás ver que depende de los siguientes paquetes:
install_requires=["geoip2>=2.1.0", "maxminddb>=1.1.1", "requests>=2.5.0", "wget>=2.2"]
Veamos si estas dependencias han sido incluidos en los metadatas generados en el paquete debian:

dante@Camelot:~/geolocate/deb_dist$ dpkg -I python3-glocate_1.3.0-1_all.deb [...] Depends: python3, python3-requests, python3:any (>= 3.3.2-2~) [...]


Obviamente no han sido incluidos. De hecho nuestro comando de compilación nos avisó de que el chequeo de dependencias había fallado al construir el paquete. Si nos fijamos en la salida del comando de compilación nos encontraremos esta parte:
[...]
I: dh_python3 pydist:184: Cannot find package that provides geoip2. Please add package that provides it to Build-Depends or add "geoip2 python3-geoip2-fixme"
line to debian/py3dist-overrides or add proper  dependency to Depends by hand and ignore this info.
I: dh_python3 pydist:184: Cannot find package that provides maxminddb. Please add package that provides it to Build-Depends or add "maxminddb python3-maxmindd
b-fixme" line to debian/py3dist-overrides or add proper  dependency to Depends by hand and ignore this info.
I: dh_python3 pydist:184: Cannot find package that provides wget. Please add package that provides it to Build-Depends or add "wget python3-wget-fixme" line t
o debian/py3dist-overrides or add proper  dependency to Depends by hand and ignore this info.
[...]
El problema es que stdeb no identifica qué paquete de Linux incluye a esos paquetes de PyPI. Por eso tenemos que fijarlos manualmente.

Para configurar manualmente a stdeb tienes que crear un fichero llamado stdeb.cfg en la misma carpeta que setup.py. Lo normal es crear una sección [DEFAULT] donde poner la configuración pero también se pueden crear secciones llamadas [nombre_de_paquete], donde el nombre del paquete coincide con el mismo argumento que el comando setup().

Por ejemplo, si averiguamos que la librería de PyPI llamada geoip2 está incluida dentro del paquete del repositorio de Ubuntu denominado python3-geoip, y la librería request de PyPI está incluida en el paquete python3-request podríamos crear un fichero stdeb.cfg con el siguiente contenido:
[DEFAULT]
X-Python3-Version: >= 3.4
Depends3: python3-geoip (>=1.3.1), python3-requests
Todas las etiquetas siguen el mismo formato que seguirían si estuvieran dentro de un fichero de configuración debian/control. Para etiquetas de dependencias, la especificación del formato es esta.

Con esta configuración, las dependencias fijadas se incluyen en el paquete debian generado:

dante@Camelot:~/geolocate/deb_dist$ dpkg -I python3-glocate_1.3.0-1_all.deb
[...]
Depends: python3, python3-requests, python3:any (>= 3.3.2-2~), python3-geoip (>= 1.3.1)
[...]

Lo que no he conseguido averiguar aún es una manera de cambiar la etiqueta de arquitectura de los paquetes generados de manera que no se generen con "Architecture: all".

A primera vista, stdeb parece una opción estupenda y verdaderamente lo es, pero tiene algunos inconvenientes dignos de consideración.

El problema es que stdeb te limita a usar sólo librerías y paquetes de python disponibles en el repositorio de paquetes estándar de Linux. Por eso si desarrollas tu aplicación usando paquetes y librerías de PyPI lo más probable es que cuando intentes identificar qué paquete de Linux incluye dichas librerías te encuentres con que dichos paquetes sólo contienen versiones más antiguas que aquellos disponibles en PyPI. Peor aún, muchas librerías de PyPI no han sido portadas a los repositorios estándar de Linux, por lo que no será posible encontrar un paquete que cubra esa dependencia. Por ejemplo, geolocate necesita usar geoip2 (v.2.1.0) el cual es fácilmente descargable de PyPI pero sólo Ubuntu 15.04 tiene un paquete disponible denominado python3-geoip, pero este viene con la versión 1.3.2 de geoip. ¿Funcionará geolocate con la versión de geoip facilitada por el paquete python3-geoip?, probablemente no. Otras dependencias de geolocate ni siquiera están en el repositorio estándar, como por ejemplo la librería de python wget (o al menos no lo había cuando escribí esta parte del artículo).

Está claro que si te gusta utilizar librerías PyPI, stdeb puede no ser tu mejor opción. Pero si desarrollas usando sólo librarías disponibles a través del gestor de paquetes estándar de Linux entonces stdeb probablemente te solucionará el problema.

FPM

FPM es una herramienta de Ruby similar a stdeb. Su principal ventaja es que puedes usar FPM para crear muchos tipos de paquetes de sistema, no sólo de Debian sino también paquetes RPM (pata distribuciones Red Hat). Y lo que es incluso más interesante de FPM es que permite conversiones de paquetes, por ejemplo de .rpm a .deb.

FPM no tiene paquete en el repositorio principal de Ubuntu, por lo que hay que descargarlo del equivalente Ruby a PyPI. Para ello primero hay que instalar los paquetes de Ruby:


dante@Camelot:~/geolocate$ sudo aptitude install ruby-dev gcc make

Después de eso puedes instalar FPM desde los repositorios de Ruby:


dante@Camelot:~/geolocate$ gem install fpm

Crear un paquete DEB es bastante simple, sólo fija el parámetro de origen de FPM como python y su objetivo como deb y dale la ruta al setup.py de tu paquete:


dante@Camelot:~/geolocate$ fpm -s python -t deb ./setup.py

FPM toma los nombres de las dependencias del setup.py y los prefija con la etiqueta que se fija con la bandera --python-package-name-prefix (si no está fijado entonces se usa python como prefijo):


dante@Camelot:~/geolocate$ dpkg -I python-glocate_1.3.0_all.deb
[...]
Depends: python-geoip2 (>= 2.1.0), python-maxminddb (>= 1.1.1), python-requests (>= 2.5.0), python-wget (>= 2.2)
[...]

El problema aquí es similar que con stdeb: esas dependencias no existen en el repositorio estándar de Ubuntu. En caso de la que las dependencias existiesen pero con nombres diferentes a los autogenereados por FPM, entonces puedes fijarlos manualmente:


dante@Camelot:~/geolocate$ fpm -s python -t deb --no-auto-depends -d "python3-geoip>=1.3.1, python3-wget" ./setup.py
[...]>
dante@Camelot:~/geolocate$ dpkg -I python-glocate_1.3.0_all.deb 
[...]
Depends: python3-geoip>=1.3.1, python3-wget
[...] 


Otra bandera útil es "-a native". Esta bandera fija la arquitectura del paquete a la de tu sistema, por eso ya no se fija a "_all" en el nombre de los paquetes.

FPM es una gran herramienta. Te permite crear paquetes RPM y es muy configurable pero en mi opinión tiene un inconveniente muy serio: tal y como pasaba con stdeb es inútil si tu aplicación importa una librería disponible en PyPI pero no en el repositorio estándar del sistema operativo.

DH-VIRTUALENV

Llegados a este punto debería estar claro que el problema principal para empaquetar una aplicación python es asegurar sus dependencias, porque el desarrollador puede haber usado librerías PyPI que no estén disponibles a través de los repositorios estándar en el lado del usuario.

Los chicos de Spotify desarrollaron un empaquetador para resolver este problema: dh-virtualenv.

Esta herramienta asume que si estás usando PyPI para desarrollar entonces lo más probable es que uses virtualenvs. Por eso dh-virtualenv incluye el virtualenv entero dentro del paquete de manera que no tengas que instalarlo en el extremo del usuario final.

Sin embargo, en mi humilde opinión dh-virtualenv tiene un inconveniente: no es más limpio que stdeb o que FPM (porque tienes que crear manualmente una carpeta debian y un fichero de reglas) acabas usando debuild como al comienzo del artículo.

VDIST

Los principales conceptos de vdist son similares a los vistos en dh-virtualenv pero vdist usa una conbinación de docker y FPM para crear un paquete estándar de sistema operativo. Esta herramienta te permite construir paquetes de tus aplicaciones creando entornos aislados para tu proyecto usando virtualenv. A primera vista vdist puede parecer complejo pero su documentación es realmente clara y en realidad es bastante simple de usar y de automatizar.

Si tu mayor problema al empaquetar aplicaciones python es asegurar que las dependencias estén presentes en el extremo del usuario final, vdist resuelve dicho problema haciendo que tu aplicación esté autocontenida y sea autosuficiente de tal manera que no dependa de los módulos de Python facilitados por los paquetes del sistema. Esto significa que los paquetes generados por vdist contienen tu aplicación, todas las dependencias de python requeridas por la misma y un interpreter de python para ejecutarla. De esa manera tu aplicación puedes ser ejecutada con el intérprete de tu elección y no con el que traiga el sistema operativo en el que estés desplegando tu aplicación.

Para asegurar que el equipo usado para construir el paquete conserve sus paquetes de sistema intactos. vdist usa docker para crear un sistema operativo limpio al crear el paquete donde instalar las dependencias necesarias antes de empaquetar la aplicación en él. Gracias a esto el equipo usado para la compilación será revertido a su estado original en cada compilación.Para cargar to aplicación en una imagen de docker, vdist descarga el código fuente de la aplicación desde un repositorio de git, por eso tener tu aplicación en BitBucket o Github es una buena idea. El código fuente se coloca en un virtualenv creado en una imagen de docker. Las dependencias de PyPI serán instalados en el virtualenv.

La primera dependencia para usar vdist es contar con vdist instalado y su demonio en ejecución. Para instalarlo en Ubuntu sólo necesitas hacer lo siguiente:


dante@Camelot:~/geolocate$ sudo aptitude install docker.io python-docker python3-docker


Tras instalar docker recuerda añadir tu usuario al grupo de docker:


dante@Camelot:~/geolocate$ sudo usermod -a -G docker dante


Puede que necesites reiniciar el sistema para estar seguro de que el grupo se actualice realmente.

La manera más fácil de instalar vdist es usar el gestor de paquetes estándar de Linux. Los paquetes de vdist están alojados en Bintray, por eso para instalarlos primero hay que incluir a Bintray en los repositorios de paquetes del sistema antes de nada más. Para hacerlo, hay que introducir los siguientes comandos:


dante@Camelot:~$ sudo apt-get update
dante@Camelot:~$ sudo apt-get install apt-transport-https
dante@Camelot:~$ sudo echo "deb [trusted=yes] https://dl.bintray.com/dante-signal31/deb generic main" | tee -a /etc/apt/sources.list
dante@Camelot:~$ sudo apt-key adv --keyserver pgp.mit.edu --recv-keys 379CE192D401AB61

Una vez que hayas añadido a Bintray en tus repositorios puedes instalar y actualizar vdist como cualquier otro paquete de sistema. Por ejemplo, en Ubuntu:


dante@Camelot:~$ sudo apt-get update
dante@Camelot:~$ sudo apt-get install vdist

Si estás en un sistema donde no tienes permisos para instalar paquetes de sistema puede que te resulte interesante instalarlo desde PyPI en un virtualenv creado ad-hoc para empaquetar tu aplicación:


(env) dante@Camelot:~/geolocate$ pip install vdist


Tras instalar vdist dispondrás de un comando de consola llamado... vdist. Sólo se consciente de que si has instalado vdist en un virtualenv, ese comando de consola sólo estará disponible dentro de ese virtualenv.

Hay muchas maneras de usar vdist, aunque creo que la manera más fácil es crear un fichero de configuración y hacer que vdist lo lea. El mismo vdist se utiliza para generar sus propios paquetes, así que su fichero de configuración es un buen ejemplo:

[DEFAULT]
app = vdist
version = 1.1.0
source_git = https://github.com/dante-signal31/${app}, master
fpm_args = --maintainer dante.signal31@gmail.com -a native --url
    https://github.com/dante-signal31/${app} --description
    "vdist (Virtualenv Distribute) is a tool that lets you build OS packages
     from your Python applications, while aiming to build an
     isolated environment for your Python project by utilizing virtualenv. This
     means that your application will not depend on OS provided packages of
     Python modules, including their versions."
    --license MIT --category net
requirements_path = /REQUIREMENTS.txt
compile_python = True
python_version = 3.5.3
output_folder = ./package_dist/
after_install = packaging/postinst.sh
after_remove = packaging/postuninst.sh

[Ubuntu-package]
profile = ubuntu-trusty
runtime_deps = libssl1.0.0, docker.io
build_deps =

[Centos7-package]
profile = centos7
runtime_deps = openssl, docker-ce

La documentación de vdist es lo suficientemente buena como para saber para qué sirve cada parámetro. Sólo hay que tener en cuenta que puedes tener un único fichero de configuración por cada paquete que quieras compilar. Sólo manten los parámetros comunes en una sección [DEFAULT] y pon los parámetros particulares de cada distribución en secciones separadas (que pueden llamarse como quieras, aunque lo mejor es usar nombres expresivos).

Una vez que tienes tu fichero puedes lanzar vdist de la siguiente manera (supongamos que el fichero de configuración se llama configuration_file):


dante@Camelot:~/$ vdist batch configuration_file

A partir de ahí comenzarás a ver un montón de texto de salida en la pantalla mientras vdist construye tus paquetes. Los paquetes generados serán colocados en la carpeta fijada en el parámetro del fichero de configuración output_folder.

El tamaño de paquete más pequeño generado por vdist es de aproximadamente 50 MB, debido a que hay que incluir una distribución completa de python  en el paquete. Ese tamaño es el precio que hay que pagar por la autosuficiencia de nuestra aplicación con respecto a las dependencias de python. Descontados esos 50 MB, el resto del tamaño se corresponderá con nuestra aplicación y sus dependencias. A primera vista puede parecer mucho, pero hoy en día ese tamaño es bastante habitual para cualquier aplicación compilada que nos encontremos por internet.

Creo que vdist es la solución de empaquetado más completa que se encuentra disponible para desplegar aplicaciones python en sistemas Linux. Con él puedes deplegar incluso en equipos linux sin ningún tipo de python instalado en absoluto, con lo que aporta un valioso aislamiento en el extremo del usuario, facilitándole la instalación además a tu usuario final.

Disclaimer: Empecé a usar vdist para escribir este artículo y he acabado siendo el nuevo desarrollador principal de la aplicación, por eso sentíos libres de comentar cualquier mejora o aportación que creais interesante.