Python packaging: Lo estás haciendo mal

2021-03-04 @ Python Madrid

Resumen

  1. Introducción
  2. Brevísima historia de una pesadilla
  3. Cómo instalar paquetes
  4. Cómo especificar dependencias para aplicaciones
  5. Cómo distribuir código
  6. Conclusiones y preguntas

¿Quién es este?

¡Yo!

  • Ingeniero aeroespacial con pasión por las órbitas 🛰
  • Ex presidente y fundador de Python España y ex organizador de PyCon España y Python Madrid 🐍
  • Mission Planning   Execution Engineer en Satellogic 🌍
  • I code like a girl 🌈
  • Activista del Conocimiento Libre y la Ciencia Abierta 🄯
  • Melómano 🎸

Sígueme! https://github.com/astrojuanlu/

Una aclaración importante

Paternalista

Brevísima historia de una pesadilla

O «cómo aprendimos a odiar pip»

Jannis Leidel, 2009

Martin Natano, 2014

"Una abominación"

2013

¡Camaradas!

"Malware"

???

¿Camaradas...?

Empecemos de nuevo

Cómo instalar paquetes

Nivel básico

✨✨✨ Instale el paquete deseado en dos sencillos pasos: ✨✨✨

$ source .venv/bin/activate
(.venv) $ python -m pip install ipython

«¿Y no había otra cosa que se llamaba easy...?»

Hagamos como que nunca existió 😇

easy_install ha muerto

«Sí, pero... ¡es que yo lo quiero en uno!»

Mal menor: pip nuevo ⚠️⚠️⚠️

$ pip install ipython  # Hmmm...

Por defecto se instala en ${HOME}/.local/lib (a escondidas se ha usado --user). ¿Queremos eso?

In [1]: import sys

In [2]: sys.prefix
Out[2]: '/usr'

In [3]: import urllib3

In [4]: urllib3.__file__
Out[4]: '/usr/lib/python3/dist-packages/urllib3/__init__.py'

In [3]: import IPython

In [4]: IPython.__file__
Out[4]: '/home/juanlu/.local/lib/python3.8/site-packages/IPython/__init__.py'

Ya tenemos dos localizaciones distintas 😨

«Sí, pero... ¡es que yo lo quiero en uno!»

Catástrofe en ciernes: pip viejo 🚨🚨🚨

$ pip install urllib3  # Hmmm...
[Errno 13] Permission denied: '/usr/lib/python3.8'
$ sudo pip install urllib3  # NOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO

Catástrofe

  • Hacer sudo pip install 🚫 puede desestabilizar dependencias críticas de tu sistema
    • Nótese que en Windows esto no es un problema
  • Para desarrollo, nunca jamás se usa el Python del sistema
  • ¡Siempre utilizar un entorno virtual!

Opciones:

Otras opciones:

¿¿Y qué pasa con Alpine??

$ docker run -it --rm --name pip-wheel python:3.8-alpine sh
/ # python -m pip install numpy
Collecting numpy
  Downloading numpy-1.20.1.zip (7.8 MB)
     |████████████████████████████████| 7.8 MB 10.8 MB/s 
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... error

    ERROR: Command errored out with exit status 1:
     command: /usr/local/bin/python /usr/local/lib/python3.8/site-packages/pip/_vendor/pep517/_in_process.py prepare_metadata_for_build_wheel /tmp/tmpzz6m5yah
         cwd: /tmp/pip-install-9mlwuba9/numpy
    Complete output (226 lines):
...
      File "numpy/core/setup.py", line 676, in get_mathlib_info
        raise RuntimeError("Broken toolchain: cannot link a simple C program")
    RuntimeError: Broken toolchain: cannot link a simple C program

🤯

  • Normalmente, pip descarga un Source Distribution o "sdist", que contiene todo lo necesario para instalar el paquete
  • Sin embargo, "todo lo necesario" a veces puede incluir código en C, C++, FORTRAN, Rust, JavaScript...
  • Y por tanto, ¡necesito herramientas que no se instalan con pip!

El formato del nombre del archivo indica la compatibilidad:

{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl

Es decir:

  • distribution: Nombre del paquete (Django, numpy)
  • version: Versión (compatible con el PEP 440)
  • python tag y abi tag: Implementación de Python (cp38, pp372-pypy3_72)
  • platform: Plataforma

Por ejemplo:

numpy-1.20.1-cp38-cp38-manylinux1_x86_64.whl

..."many what"???

For Linux, the situation is much more delicate.

PEP 513 -- A Platform Tag for Portable Linux Built Distributions

  • No hay "un" Linux, hay muchos
  • Hay varias causas (explicadas en el PEP) de incompatibilidad entre binarios de Linux
  • La más importante, dependencia dinámica en diferentes versiones de la GNU C Library, glibc
  • El PEP 513 estandariza unas versiones mínimas, basadas en CentOS 5.11, y le da el nombre manylinux1
  • Después vinieron manylinux2010 (CentOS 6), manylinux2014 (CentOS 7), y vendrán más

En resumen:

  • python -m pip install + venv = ❤️
  • pip está bien mantenido y está recibiendo muchas mejoras ❤️
  • ¡Cuidado con los wheels!

Cómo especificar dependencias para aplicaciones

Nivel intermedio

✨✨✨ Especifique las dependencias deseadas en dos sencillos pasos: ✨✨✨

(.venv) $ echo "django<3" > requirements.in
(.venv) $ python -m pip install pip-tools && pip-compile

pip-compile toma como entrada un archivo requirements.in y produce un requirements.txt con todas las versiones fijadas:

(.venv) $ cat requirements.txt
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile
#
django==2.2.19
    # via -r requirements.in
pytz==2021.1
    # via django
sqlparse==0.4.1
    # via django

Perfecto para tener un entorno reproducible 💯

Una vez fijadas, ¡por defecto no se actualizan!

(.venv) $ echo "django" > requirements.in
(.venv) $ pip-compile
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile
#
django==2.2.19
    # via -r requirements.in
pytz==2021.1
    # via django
sqlparse==0.4.1
    # via django

Se acabaron los pipelines rotos por nuevas versiones ✨

Solo se actualizan si lo pedimos:

(.venv) $ pip-compile -P django
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile
#
asgiref==3.3.1
    # via django
django==3.1.7
    # via -r requirements.in
pytz==2021.1
    # via django
sqlparse==0.4.1
    # via django

Y, para poner nuestro entorno al día:

(.venv) $ pip-sync
Collecting asgiref==3.3.1
  Using cached asgiref-3.3.1-py3-none-any.whl (19 kB)
Collecting django==3.1.7
  Using cached Django-3.1.7-py3-none-any.whl (7.8 MB)
Collecting pytz==2021.1
  Using cached pytz-2021.1-py2.py3-none-any.whl (510 kB)
Collecting sqlparse==0.4.1
  Using cached sqlparse-0.4.1-py3-none-any.whl (42 kB)
Installing collected packages: sqlparse, pytz, asgiref, django
Successfully installed asgiref-3.3.1 django-3.1.7 pytz-2021.1 sqlparse-0.4.1

...que es casi lo mismo que hacer:

(.venv) $ python -m pip install -r requirements.txt 
Requirement already satisfied: asgiref==3.3.1 in ./.venv/lib/python3.8/site-packages (from -r requirements.txt (line 7)) (3.3.1)
Requirement already satisfied: django==3.1.7 in ./.venv/lib/python3.8/site-packages (from -r requirements.txt (line 9)) (3.1.7)
Requirement already satisfied: pytz==2021.1 in ./.venv/lib/python3.8/site-packages (from -r requirements.txt (line 11)) (2021.1)
Requirement already satisfied: sqlparse==0.4.1 in ./.venv/lib/python3.8/site-packages (from -r requirements.txt (line 13)) (0.4.1)

😍😍😍

Otras alternativas:

En resumen:

Cómo distribuir código

Nivel avanzado

✨✨✨ Distribuya su código en tres sencillos pasos: ✨✨✨

(.venv) $ cat saludo.py  # Este no cuenta ;)
"""
Un saludo
"""

__version__ = "0.1.0"

print("¡Hola, mundo!")
(.venv) $ python -m pip install flit && flit init
(.venv) $ python -m pip install build && python -m build
(.venv) $ python -m pip install twine && twine upload dist/*

Pero, ¿qué es flit, y qué está pasando aquí?

(.venv) $ ls
dist  LICENSE  pyproject.toml  saludo.py

¡No hay setup.py! 😮 Rebobinemos por un momento...

  • Tradicionalmente siempre se han usado archivos setup.py para especificar los metadatos del proyecto
  • Estos archivos suelen empezar así:
from setuptools import setup

setup(
    ...
  • ...pero, como hemos visto arriba, setuptools tiene ejem algunos detractores
  • Necesitamos eliminar el acoplamiento entre el backend (setuptools) y el frontend (pip)

«¿No había otra cosa que se llamaba...?»

Elevemos una oración por su alma 😇

distutils ha muerto

  • El PEP 518 estandariza una forma de especificar las dependencias en tiempo de instalación, que se escriben en el nuevo pyproject.toml
    • ¡No confundir con las dependencias en tiempo de ejecución!
  • Y el PEP 517 estandariza una forma de especificar cómo el frontend tiene que llamar al backend
[build-system]
requires = ["setuptools", "wheel"]  # PEP 518
build-backend = "setuptools.build_meta"  # PEP 517

(Para más información, Brett Cannon lo explica muy bien)

Por tanto, en lugar de hacer el antiguo:

(.venv) $ python setup.py install  # Buuuuuuuuuuuuuu 👎

¡Ahora podemos hacer esto!

(.venv) $ python -m pip install .

Y pip ya sabe hacer el resto 🎉

De paso, ¡python -m pip uninstall funciona! ✨

Insisto: dejad de ejecutar setup.py. Haced caso a Paul, es muy majete.

Nunca uses setup.py

Equivalencias:

  • python setup.py install -> python -m pip install .
  • python setup.py develop -> python -m pip install -e . ("instalaciones editables", ¡muy útiles!)
  • python setup.py sdist bdist_wheel -> python -m build (en seguida lo explicamos)
  • Algunos setup.py son endiablados 😵 (por ejemplo el de SciPy) - por suerte, setuptools permite todo esto y más
  • Si necesitas setuptools, es recomendable usar metadatos estáticos en setup.cfg:
[metadata]
name = mypackage
version = 0.0.1

[options]
packages = mypackage
install_requires =
  requests
  importlib; python_version == "2.6"
  • Sin embargo, ¡muchos proyectos no necesitan esa complejidad!

Y para eso tenemos flit:

(.venv) $ flit init
Module name [saludo]: 
Author [Juan Luis Cano Rodríguez]: 
Author email [[email protected]]: 
Home page [https://github.com/astrojuanlu/python-saludo]: 
Choose a license (see http://choosealicense.com/ for more info)
1. MIT - simple and permissive
2. Apache - explicitly grants patent rights
3. GPL - ensures that code based on this is shared with the same terms
4. Skip - choose a license later
Enter 1-4 [1]: 

Written pyproject.toml; edit that file to add optional extra info.
(.venv) $ cat pyproject.toml
[build-system]
requires = ["flit_core >=2,<4"]
build-backend = "flit_core.buildapi"

[tool.flit.metadata]
module = "saludo"
author = "Juan Luis Cano Rodríguez"
author-email = "[email protected]"
home-page = "https://github.com/astrojuanlu/python-saludo"
classifiers = [ "License :: OSI Approved :: MIT License",]

¡Y nada más! ✨

Y ha llegado el momento de la verdad: generamos nuestras Source Distribution y Built Distribution usando build:

(.venv) $ python -m build
(.venv) $ ls dist/
saludo-0.1.0-py2.py3-none-any.whl  saludo-0.1.0.tar.gz

...¡y las subimos a PyPI usando twine!

(.venv) $ twine upload dist/*

PyPI

«¿Significa eso que, dependiendo de si elijo setuptools, flit, o poetry, tengo que especificar los metadatos de una manera totalmente distinta?»

Sí 😓

...¡pero no por mucho tiempo!

PEP 621

Alternativas:

  • ¡Poetry!
  • enscons (poca actividad en GitHub)
  • scikit-build o mesonpep517 para código con extensiones en C, C++, FORTRAN u otros
  • ?

En resumen:

  • flit + build + twine = ❤️
  • Para las cosas complicadas, siempre estará setuptools
  • No importa qué sistema usemos, pronto pyproject.toml será parecido en todos

Conclusiones

  • ¡Larga vida a pip! 🎉 Y nunca jamás nombrar a easy_install 🚫
  • ¡Larga vida a venv! 🎉 Y nunca jamás hacer sudo pip install 🚫
  • ¡Larga vida a los wheels! 🎉 Y si no hay para tu plataforma, tendrás que compilarlo o buscar una alternativa 🔨
  • ¡Larga vida a flit! 🎉 Y, si lo necesitas, puedes seguir usando setuptools ✔️
  • No te compliques la vida 😉 https://github.com/astrojuanlu/cookiecutter-pylib
  • ¡Y nos mantenemos en contacto! <[email protected]>

¡Muchas gracias!

El ornitorrinco de la paquetería