pyproject.toml en esteroides con Hatch

Si alguna vez te has visto en la necesidad de compartir tu código con el resto del internet, es muy probable que te haya tocado interactuar con un archivo llamado setup.py.

Pues bien, de un tiempo acá esta era la forma que se usaba para empaquetar y distribuir paquetes en Python, sin embargo con la llegada del PEP-518 se introdujo un nuevo archivo de nombre pyproject.toml que trataría de suplir o cumplir las mismas funcionalidades que el setup.py, a su vez este archivo tendría mejoras significativas gracias al PEP-517 y el PEP-660 donde se trataría de estandarizar la interfaz para el empaquetado, continuando con elPEP-621 y el PEP-631 para formalizar el formato lingüístico a utilizar en Python.

Como resultado de todos estos cambios la ejecución o el uso del archivo setup.py esta ahora deprecado, y aunque esto sonara a un apocalipsis, por que si hablas con algún desarrollador en Python con algunos años de experience, te vas a encontrar con alguna historia llena de errors un tanto difíciles de encontrar, por que durante el proceso de empaquetaniento también es el momento adecuado para que nuevas herramientas surjan y mejoren la forma en que se trabaja hoy en día.

Usando hatch

Descrito a si mismo como un administrador de proyectos de Python moderno y extensible, es el nuevo amigo en el vecindario. Se caracteriza por tener un sistema de empaquetamiento estandarizado con compilaciones reproducibles de forma predeterminada, así como una gestión robusta de entornos virtuales con soporte para scripts personalizados, entre otros.

A manera personal se siente como la evolución de proyectos como poetry o flit, su interfaz de línea de comandos es bastante robusta y de fácil adopción, así como la capacidad de generar entornos virtuales de manera isolada y la ejecución de XXX que te ayudaran a mejorar o simplificar las necesidades de tu proyecto.

Vamos a tomar como suposición que el código que estas tratando de migrar hace uso de setup.py , para ello tomaremos como ejemplo mi paquete para saber los códigos postales de Nicaragua, y a como se puede notar contiene lo siguiente dentro del archivo setup.py:

from setuptools import setup, find_packages

with open('README.md') as readme_file:
    readme = readme_file.read()

with open('HISTORY.md') as history_file:
    history = history_file.read()

setup_requirements = ['pytest-runner', ]
test_requirements = ['pytest', ]

setup(
    author="Oscar Cortez",
    author_email='om.cortez.2010@gmail.com',
    name='postalcodes_ni',
    version='1.2.0',
    classifiers=[
        'Development Status :: 5 - Production/Stable',
        'Intended Audience :: Developers',
        'Topic :: Software Development :: Libraries',
        'License :: OSI Approved :: MIT License',
        'Natural Language :: English',
        'Natural Language :: Spanish',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.4',
        'Programming Language :: Python :: 3.5',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
    ],
    description="Python package for handle Nicaragua postal codes",
    license="MIT license",
    long_description=readme + '\n\n' + history,
    long_description_content_type="text/markdown",
    include_package_data=True,
    python_requires='>=3',
    keywords='postalcodes nicaragua',
    packages=find_packages(include=['postalcodes_ni']),
    setup_requires=setup_requirements,
    test_suite='tests',
    tests_require=test_requirements,
    project_urls={
        'Documentation': 'https://postalcodes-ni.readthedocs.io',
        'Funding': 'https://donate.pypi.org',
        'Say Thanks!': 'http://saythanks.io/to/oscarmcm',
        'Source': 'https://github.com/oscarmcm/postalcodes-ni/',
        'Tracker': 'https://github.com/oscarmcm/postalcodes-ni/issues',
    },
    url='https://github.com/oscarmcm/postalcodes-ni',
    zip_safe=False,
)

La manera mas fácil de migrar un proyecto antiguo es ejecutando hatch new —-init para generar un nuevo archivo pyproject.toml a partir de la información que tengamos en nuestro setup.py, en el caso de este ejemplo se ve de la siguiente manera:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "postalcodes-ni"
dynamic = ["version"]
description = "Python package for handle Nicaragua postal codes"
readme = "README.md"
license = "MIT license"
requires-python = ">=3"
authors = [
    { name = "Oscar Cortez", email = "demo@mail.com" },
]
keywords = [
    "nicaragua",
    "postalcodes",
]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Natural Language :: English",
    "Natural Language :: Spanish",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.4",
    "Programming Language :: Python :: 3.5",
    "Programming Language :: Python :: 3.6",
    "Programming Language :: Python :: 3.7",
    "Topic :: Software Development :: Libraries",
]

[project.urls]
Documentation = "https://postalcodes-ni.readthedocs.io"
Funding = "https://donate.pypi.org"
Homepage = "https://github.com/oscarmcm/postalcodes-ni"
"Say Thanks!" = "http://saythanks.io/to/oscarmcm"
Source = "https://github.com/oscarmcm/postalcodes-ni/"
Tracker = "https://github.com/oscarmcm/postalcodes-ni/issues"

[tool.hatch.version]
path = "postalcodes_ni/__init__.py"

[tool.hatch.build.targets.sdist]
include = [
    "/postalcodes_ni",
]

Pero en un dado caso que se este iniciando desde cero, hatch tiene la capacidad de crear un proyecto nuevo usando hatch new —-interactive, ahi se responderán un par de preguntas y automáticamente creara la estructura para el nuevo proyecto de Python.

Project name: postalcodes
Description []: Nicaragua postal codes

postalcodes
├── postalcodes
│   ├── __about__.py
│   └── __init__.py
├── tests
│   └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml

De igual manera el archivo pyproject.toml generado en el proyecto desde cero, es casi parecido a el que se genero a partir de setup.py:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "postalcodes"
description = 'Nicaragua postal codes'
readme = "README.md"
requires-python = ">=3.7"
license = "MIT"
keywords = []
authors = [
  { name = "Oscar Cortez", email = "om.cortez.2010@gmail.com" },
]
classifiers = [
  "Development Status :: 4 - Beta",
  "Programming Language :: Python",
  "Programming Language :: Python :: 3.7",
  "Programming Language :: Python :: 3.8",
  "Programming Language :: Python :: 3.9",
  "Programming Language :: Python :: 3.10",
  "Programming Language :: Python :: 3.11",
  "Programming Language :: Python :: Implementation :: CPython",
  "Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
dynamic = ["version"]

[project.urls]
Documentation = "https://github.com/unknown/postalcodes#readme"
Issues = "https://github.com/unknown/postalcodes/issues"
Source = "https://github.com/unknown/postalcodes"

[tool.hatch.version]
path = "postalcodes/__about__.py"

[tool.hatch.envs.default]
dependencies = [
  "pytest",
  "pytest-cov",
]
[tool.hatch.envs.default.scripts]
cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=postalcodes --cov=tests {args}"
no-cov = "cov --no-cov {args}"

[[tool.hatch.envs.test.matrix]]
python = ["37", "38", "39", "310", "311"]

[tool.coverage.run]
branch = true
parallel = true
omit = [
  "postalcodes/__about__.py",
]

[tool.coverage.report]
exclude_lines = [
  "no cov",
  "if __name__ == .__main__.:",
  "if TYPE_CHECKING:",
]

Los entornos y comandos en hatch

Una vez tengamos generado nuestro archivo pyproject.py y gracias a que usamos hatch tenemos a la disposición la herramienta para la creación de entornos virtuales totalmente isolados que pueden ser usados para los procesos de pruebas o de documentación, si tener que agregar validaciones a las dependencias del proyecto.

Todos los entornos se definen como secciones dentro de la tabla tool.hatch.envs de la siguiente manera [tool.hatch.envs.<ENV_NAME>]:

[tool.hatch.envs.test]
dependencies = [
  "coverage[toml]",
  "pytest",
  "pytest-cov",
  "pytest-mock",
]

Gracias a ello ya puedes ir eliminando los archivos de requerimientos para pruebas o para desarrollo, menos carga cognitiva, mas centralización.

Comandos

Y si también tienes esos grandes archivos Makefile con varios comandos, hatch ofrece la misma solución pero de manera centralizada y que se pueden ejecutar solo en un entorno en especifico:

[tool.hatch.envs.test]
dependencies = [
  "coverage[toml]",
  "pytest",
  "pytest-cov",
  "pytest-mock",
]
[tool.hatch.envs.test.scripts]
run-coverage = "pytest --cov-config=pyproject.toml --cov=pkg --cov=tests"
check-cov = "run-coverage --no-cov"

En el ejemplo de arriba podemos notar que hay una nueva tabla que contiene una lista de comandos, estos solo están disponibles en el entorno llamado test y para ejecutarlos basta con hacer lo siguiente:

hatch run test:check-cov

Lo cual sera expandido a:

pytest --cov-config=pyproject.toml --cov=pkg --cov=tests --no-cov

Los cambios

Y ya para ir cerrando este post introductorio a pyproject.toml y hatch hay algunas cosas que cambian cuando decides usar este como, primero los archivos setup.py, setup.cfg, y Manifest.in ya no serán necesarios; si haces uso de los scripts entonces también puedes eliminar Makefile de tu proyecto, de igual manera los archivos de requerimientos para prueba o desarrollo.

Ya no sera necesario ejecutar python setup.py install o python setup.py develop en contraposición basta con usar pip install o pip install -e . en la raíz del proyecto.

Es muy probable que puedas eliminar la dependencia de tox si haces uso de las matrices en hatch.

Hasta aca el blog post de hoy, en otra entrada veremos mas a detalle como sacarle provecho a hatch y el uso de las matrices para entornos de prueba.

Y eso es todo, espero que te haya gustado este post.