Title: Poetry: un gran gestor de proyectos para Python

Date: 2020-01-17
Content:

Dos de las cosas que peor están resueltas en Python es le definición de dependencias y el aislamiento de proyectos.
Seamos sinceros, almacenar las dependencias de un proyecto en requierements.txt de forma manual o con el comando:

$> pip freeze > requierements.txt

es una manera bastante mala de llevar un continuo seguimiento de todas las dependencias, ya que es muy probable que no siempre ese archivo se mantenga actualizado, ya que se debe escribir de manera manual y separada del proyecto en si.
Los entornos virtuales son otro tema que está resuelto a medias porque, de nuevo, se deben activar de manera manual y eso al final solo está agregando complejidad innecesaria para poder arrancar el proyecto.
Maneras de resolver esto hay muchas y hoy les vengo a platicar de mi preferida, Poetry.

Poetry es una manera de tener correctamente controladas las dependencias del proyecto y poderlo aislar de una manera transparente para nosotros.
Su manera de funcionar no dista demasiado de gestores de proyectos de otros lenguajes como npm, yarn, cargo o composer. En esencia, es un archivo que contiene toda la información y requerimientos que nuestro proyecto necesita para funcionar.

Instalación

La instalación es bastante sencilla y como única dependencia, debemos ya tener instalado Python en nuestro equipo.

Si nuestro equipo tiene alguna distribución de GNU/Linux, MacOS o es windows con bashonwindows, basta con ejecutar el comando:

$> curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python

O si nada mas estamos en Windows y tenemos powershell, se instala con el siguiente comando (Todo este blog fue escrito enteramente en GNU/Linux y no fue probado en Windows):

$> (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python

Para verificar que se instaló correctamente se ejecuta el comando:

$> poetry --version
Poetry 0.12.17

Mas información acerca de la instalación en la web oficial.

Creación de un nuevo proyecto

Ahora si, dejando la parte mas tediosa a un lado, comencemos ahora si con lo interesante. Para crear un nuevo proyecto de Poetry debes escribir el siguiente comando:

$> poetry new «nombre del proyecto»

Lo cual creará los siguientes archivos y carpetas

example/
├── example
│   └── __init__.py
├── pyproject.toml
├── README.rst
└── tests
    ├── __init__.py
    └── test_example.py

y si queremos hacer compatible un proyecto ya existente con Poetry, solo debemos escribir:

$> poetry init

Nos hará una serie de preguntas como, ¿Cuál es el nombre del proyecto?, ¿En qué versión va?, ¿Quién lo creó? entre otras cosas.
Ya sea que lo hayamos creado con new o con init, en ambos casos nos creará un archivo pyproject.toml el cual es el responsable de que la magia ocurra. Dicho archivo contiene la siguiente información:

# pyproject.toml
[tool.poetry]
name = "example"
        ^ ^ ^ ^
        ╚═╩═╩═╩═ Nombre del proyecto
version = "0.1.0"
           ^ ^ ^
           ╚═╩═╩═ Versión actual del proyecto
description = "Proyecto de prueba"
               ^ ^ ^ ^ ^ ^ ^ ^ ^ 
               ╚═╩═╩═╩═╩═╩═╩═╩═╩═ Descripción del proyecto
                                  (útil si se quiere subir posteriormente a PyPI)
authors = ["kirbylife"]
            ^ ^ ^ ^ ^
            ╚═╩═╩═╩═╩═ La o las personas que mantienen el proyecto

[tool.poetry.dependencies]
python = "^3.6"
          ^ ^ 
          ╚═╩═ Versión de Python que usará el proyecto
^ ^ ^ ^ ^
╚═╩═╩═╩═╩═ En esta parte del documento irán las demás dependencias del proyecto

[tool.poetry.dev-dependencies]
pytest = "^3.0"

^ ^ ^ ^ ^
╚═╩═╩═╩═╩═ Aquí irán las dependencias que solo serán necesarias para pruebas o depuración

[build-system]
requires = ["poetry>=0.12"]
             ^ ^ ^ ^ ^ ^
             ╚═╩═╩═╩═╩═╩═ Versión de Poetry que se está utilizando
build-backend = "poetry.masonry.api"
                 ^ ^ ^ ^ ^ ^ ^ ^ ^
                 ╚═╩═╩═╩═╩═╩═╩═╩═╩═ Compositor del proyecto
                                    (solo tocar en caso de saber lo que estás haciendo)

Como podemos ver, es un resumen bastante completo del proyecto en si, ya que incluye desde meta-información como el nombre, la descripción y las personas que los mantienen, hasta la dependencias que el proyecto necesita.

Manejo de dependencias

Una de las cosas que mas me gusta de Poetry es su manejo de dependencias, y es que, tenerlas todas recopiladas y que no se puedan utilizar dentro del proyecto si no está listada es un gran alivio para evitar "sorpresas" cuando se quiera subir a producción.
Para añadir una nueva dependencia es bastante sencillo y lo podemos hacer desde la linea de comando con la instrucción poetry add como en este ejemplo:

$> poetry add requests
Creating virtualenv example-py3.6 in /home/kirbylife/.cache/pypoetry/virtualenvs
Using version ^2.22 for requests
...
  - Installing requests (2.22.0)

Una vez completado todo el proceso de instalación podemos fijarnos que se agregó el paquete requests al apartado de dependencias dentro de nuestro archivo pyproject.toml.

# pyproject.toml
...
[tool.poetry.dependencies]
python = "^3.6"
requests = "^2.22"
^ ^ ^ ^ ^ ^ ^ ^ ^
╚═╩═╩═╩═╩═╩═╩═╩═╩═ Paquete "requests" añadido a la lista de dependencias
                   con todo y su versión

...

Otra forma de añadir dependencias es escribirla manualmente en el archivo pyproject.toml y posteriormente actualizar el listado de dependencias. Aquí un ejemplo:

# pyproject.toml
...
[tool.poetry.dependencies]
python = "^3.6"
requests = "^2.22"
beautifulsoup4 = "4.8.2"
^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Paquete "beautifulsoup4" escrito manualmente con todo y versión
...

La versión del paquete se puede consultar directamente en la web de PyPI.
Y una vez modificado solo basta ejecutar la instrucción update de Poetry para efectuar los cambios:

$> poetry update
Updating dependencies
...
  - Installing beautifulsoup4 (4.8.2)

Lo mismo aplica para las dependencias de pruebas y depuración. Basta con agregar la bandera --dev o -D al comando add de Poetry para indicar que coloque esa dependencia en el apartado de "dev", o la podemos escribir manualmente en su correspondiente apartado y ejecutar la instrucción update.

$> poetry add ipython --dev
Using version ^7.11 for ipython
...
  - Installing ipython (7.11.1)

Agregando así la dependencia a nuestro listado de paquetes para depuración.

# pyproject.toml
...
[tool.poetry.dev-dependencies]
pytest = "^3.0"
ipython = "^7.11"
...

¡Y listo! dependencias añadidas de una manera controlada, simple ¿verdad?.

Escribiendo código

Bien, ya sabemos como agregar dependencias, pero ahora... ¿Cómo y en donde escribimos nuestro código?.
Pues bien, aunque el código realmente lo podemos escribir y añadir en la raíz del proyecto, lo mas adecuado sería crear una carpeta con el mismo nombre de nuestro proyecto dentro de la carpeta raíz con su correspondiente archivo __init__.py y ahí colocar el código.
Esto se hace de manera automática si el proyecto lo creamos de 0 con la herramienta de linea de comandos de Poetry, pero lo tendremos que hacer a mano si el proyecto lo creamos con la instrucción init.
Aquí un ejemplo de como quedaría un proyecto simple con esta estructura.

example/
├── example
│   ├── __init__.py
│   └── main.py
│       ^ ^ ^ ^
│       ╚═╩═╩═╩══ Creé un nuevo archivo llamado "main.py"
├── poetry.lock
├── pyproject.toml
└── README.rst

Y dentro del archivo main.py está el siguiente código de ejemplo:

# example/main.py

import re


def is_prime(n):
    return not re.match(r"-?$|^(--+?)\1$", "-"*n)

def primes(a, b):
    return list(filter(is_prime, range(a, b)))

def main():
    print("Calculo de números primos")
    a = int(input("Límite inferior: "))
    b = int(input("Límite superior: "))

    result = primes(a, b)
    print(f"Todos los números primos que hay entre {a} y {b} son:")
    print(", ".join(map(str, result)))

if __name__ == "__main__":
    main()

Como pueden ver, es un código sin nada demasiado destacable (no me maten por validar números primos con regex, es solo un código de ejemplo). De esta manera tan simple y realmente cotidiana, se puede mantener una estructura poco caótica de nuestro proyecto lo cual, a la larga, será muy beneficioso.
Pero ahora, ¿Cómo se corre el código?.

Ejecución de comandos

Bien, ya sabemos agregar dependencias y sabemos como debemos mantener la estructura de nuestro proyecto, pero ¿Cómo puedo ejecutar mi proyecto?
Poetry incluye una instrucción llamada run que nos permite ejecutar comandos que se encuentran encapsulados dentro del entorno virtual de nuestro proyecto. Así ejecutaríamos el código anteriormente escrito:

$> poetry run python example/main.py 
Calculo de números primos
Límite inferior: 10
Límite superior: 50
Todos los números primos que hay entre 10 y 50 son:
11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 43, 45, 47, 49

Como podemos apreciar, solo debemos escribir poetry run + el comando de toda la vida que queramos ejecutar.
Aparte de tener esta opción, Poetry nos ofrece una manera de añadir atajos a al instrucción run para que sea mas sencillo ejecutar nuestro proyecto.
Esto se hace añadiendo una nueva sección al archivo pyproject.toml llamada "scripts". Aquí un ejemplo de las partes de un atajo:

# pyproject.toml
...
[tool.poetry.dev-dependencies]
pytest = "^3.0"
ipython = "^7.11"

[tool.poetry.scripts]
start = "example.main:main"
^ ^ ^    ^ ^ ^ ^ ^ ^  ^ ^
║ ║ ║    ║ ║ ║ ║ ║ ║  ╚═╩═ Nombre de la función a ejecutar
║ ║ ║    ║ ║ ║ ║ ╚═╩═ Nombre del archivo que contiene la función
║ ║ ║    ╚═╩═╩═╩═ Nombre de la carpeta que contiene el archivo
╚═╩═╩═ Nombre del atajo

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

Una vez añadida esa opción a nuestro archivo todopoderoso pyproject.toml, solo bastará con ejecutar el comando:

>$ poetry run start
              ^ ^ ^
              ╚═╩═╩═ Nombre del atajo
Calculo de números primos
Límite inferior: 10
Límite superior: 50
Todos los números primos que hay entre 10 y 50 son:
11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 43, 45, 47, 49

Y listo, eso simplifica bastante las cosas, ¿no? ahora es mucho mas fácil recordar el comando para cumplir con la acción.
En el ejemplo anterior ejecuté el código que había escrito, pero no solo sirve para eso, ¿Recuerdan que instalé ipython como una dependencia de depuración? pues bien, para abrir el shell de ipython solo debemos escribir en la terminal:

$> poetry run ipython
Python 3.6.9 (default, Dec  6 2019, 13:53:25) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.11.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]:

Esto funciona con todos los demás paquetes que agreguen un binario que puede ser accesible desde la terminal.

Pruebas unitarias

Escribir pruebas unitarias es una parte que no podemos omitir NUNCA si queremos escribir código fiable y Poetry puede ser un buen aliado para llevar a cabo este objetivo.
Para hacer esta tarea mucho mas sencilla, dentro del proyecto debe existir una carpeta llamada "test" y dentro de esa carpeta, todos los archivos en donde escribiremos nuestras pruebas unitarias. Si nuestro proyecto lo creamos desde 0 con Poetry, esto ya vendrá incluido, pero si lo iniciamos con la instrucción init tocará hacer la carpeta y los archivos a mano.
importante, los archivos de pruebas deben tener al inicio de su nombre "test_".
Al final la estructura del proyecto queda así:

example/
├── example
│   ├── __init__.py
│   └── main.py
├── poetry.lock
├── pyproject.toml
├── README.rst
└── tests
    ├── __init__.py
    └── test_example.py
        ^ ^ ^ ^ ^ ^ ^ ^
        ╚═╩═╩═╩═╩═╩═╩═╩═ Archivo que contendrá nuestras pruebas unitarias.

Aquí dejo de ejemplo unas cuantas pruebas unitarias.

# tests/test_example.py

from example import __version__
from example.main import is_prime, primes

def test_version():
    assert __version__ == '0.1.0'

def test_2_is_prime():
    assert is_prime(2)

def test_4_is_not_prime():
    assert not is_prime(4)

def test_113_is_prime():
    assert is_prime(113)

def test__minus_1_is_not_prime():
    assert not is_prime(-1)

def test_range_from_10_to_30_of_prime_numbers():
    numbers = primes(10, 30)
    assert numbers == [11, 13, 15, 17, 19, 21, 23, 25, 27, 29]

Para correr los tests basta con llamar al ejecutable pytest utilizando la instrucción "run":

$> poetry run pytest
============================= test session starts =============================
platform linux -- Python 3.6.9, pytest-3.10.1, py-1.8.1, pluggy-0.13.1
rootdir: /home/kirbylife/Temp/example, inifile:
collected 6 items                                                                                 

tests/test_example.py ......                                            [100%]

========================== 6 passed in 0.02 seconds ===========================

Y si queremos mapear un atajo por si en un futuro agregamos mas tests con diferentes herramientas, debemos modificar un poco el archivo tests/__init__.py para que quede así:

# tests/__init__.py

from pytest import main as pytest_main

def run_all_tests():
    pytest_main()

    ^ ^ ^ ^ ^ ^ ^
    ╚═╩═╩═╩═╩═╩═╩═ Aquí se podrán apilar los tests de todos los frameworks que necesitemos

y el atajo en el archivo pyproject.toml quedaría así:

# pyproject.toml
...
[tool.poetry.scripts]
start = "example.main:main"
tests = "tests:run_all_tests"
^ ^ ^    ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
║ ║ ║    ║ ║ ║ ╚═╩═╩═╩═╩═╩═╩═ Nombre de la función que dispara todas las pruebas
║ ║ ║    ╚═╩═╩═ Nombre de la carpeta donde se encuentras todas las pruebas
╚═╩═╩═ Nombre del atajo
...

Y como mas o menos lo pueden intuir, las pruebas se correrán con el comando:

❯ poetry run tests
             ^ ^ ^
             ╚═╩═╩═ Nombre del atajo
============================= test session starts =============================
platform linux -- Python 3.6.9, pytest-3.10.1, py-1.8.1, pluggy-0.13.1
rootdir: /home/kirbylife/Temp/example, inifile:
collected 6 items                                                                                 

tests/test_example.py ......                                            [100%]

========================== 6 passed in 0.02 seconds ===========================

Crear paquete wheels

Poetry no solo nos permite mantener en control todo lo relacionado con nuestros proyectos, sino que también nos hace la vida mas sencilla en caso de que queramos distribuirlos ya que nos permite construir todo nuestro proyecto en un paquete de wheels.
Hacerlo es súper sencillo, solo ejecutamos la instrucción build:

$> poetry build
Building example (0.1.0)
 - Building sdist
 - Built example-0.1.0.tar.gz

 - Building wheel
 - Built example-0.1.0-py3-none-any.whl

Eso creará una carpeta llamada "dist" y dentro nuestro paquete wheels. Estos paquetes, por si no lo sabes, es la manera en la que Python por defecto maneja los paquetes. Si queremos que otra persona utilice nuestro hermoso código, no es necesario que tenga Poetry instalado, solo le compartimos el archivo .whl y lo puede instalar en su equipo utilizando pip de toda la vida:

$> pip install example-0.1.0-py3-none-any.whl --user
Processing ./example-0.1.0-py3-none-any.whl
...
Successfully installed beautifulsoup4-4.8.2 example-0.1.0 requests-2.22.0

Y ahora será capaz de utilizar nuestro código en su propio shell. Aquí una demo:

In [1]: from example import main                                                                   

In [2]: main.main()                                                                                
Calculo de números primos
Límite inferior: 10
Límite superior: 30
Todos los números primos que hay entre 10 y 30 son:
11, 13, 15, 17, 19, 21, 23, 25, 27, 29

In [3]: main.is_prime(100)                                                                         
Out[3]: False

In [4]: main.is_prime(113)                                                                         
Out[4]: True

Conclusiones

Bueno, a grandes rasgos eso es Poetry. ¿Qué les pareció? ¿Lo usarían en sus proyectos personales o profesionales? Personalmente soy un gran fan de este gestor de proyectos y considero que es indispensable una herramienta como estas, tanto en proyecto pequeñitos como en los proyectos mas gigantescos.
Si no te terminó de convencer Poetry, tambien puedes investigar acerca de otras herramientas que cumplen funciones parecidas como pipenv o virtualenvwrapper pero lo importante es que usen alguno.
Subí el proyecto que utilice para realizar este post a mi gitlab, por si quedaron dudas sobre la estructura o el código en general.

Sección extra: Posibles problemas

Error [TooManyRedirects] al intentar añadir un nuevo paquete

Para solucionarlo es muy sencillo, basta con limpiar el caché de Poetry así:

$> poetry cache:clear --all pypi

Delete 332 entries? (yes/no) [no] yes

Fuentes

  1. https://python-poetry.org/docs/
  2. https://github.com/python-poetry/poetry/issues/728
  3. https://docs.pytest.org/en/latest/usage.html