Capítulo 4. Gestión de la dependencia

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Los programadores de Python se benefician de un rico ecosistema de bibliotecas y herramientas de terceros. Subirte a hombros de gigantes tiene un precio para : los paquetes de los que dependes para tus proyectos suelen depender a su vez de una serie de paquetes. Todos ellos son objetivos en movimiento: mientras un proyecto siga vivo, sus responsables publicarán una serie de versiones para corregir errores, añadir funciones y adaptarse a la evolución del ecosistema.

Gestionar las dependencias es un reto importante cuando mantienes el software a lo largo del tiempo. Necesitas mantener tu proyecto actualizado, aunque sólo sea para cerrar las vulnerabilidades de seguridad a tiempo. A menudo esto requiere actualizar tus dependencias a la última versión -pocos proyectos de código abierto tienen recursos para distribuir actualizaciones de seguridad para versiones anteriores. ¡Estarás actualizando dependencias todo el tiempo! Hacer que el proceso sea lo menos friccionado, automatizado y fiable posible tiene una enorme recompensa.

Dependencias de un proyecto Python son los paquetes de terceros que deben instalarse en su entorno.1 Lo más habitual es que incurras en una dependencia de un paquete porque distribuye un módulo que importas. También decimos que el proyecto requiere un paquete.

Muchos proyectos también utilizan herramientas de terceros para tareas de desarrollo, como ejecutar el conjunto de pruebas o crear documentación. Estos paquetes se conocen como dependencias de desarrollo: los usuarios finales no los necesitan para ejecutar tu código. Un caso relacionado son las dependencias de compilación del Capítulo 3, que te permiten crear paquetes para tu proyecto.

Las dependencias son como los parientes. Si dependes de un paquete, sus dependencias también son tus dependencias, por mucho que te gusten. Estos paquetes se conocen como dependencias indirectas; puedes pensar en ellos como en un árbol con tu proyecto en su raíz.

Este capítulo explica cómo gestionar eficazmente las dependencias. En la siguiente sección, aprenderás a especificar las dependencias en pyproject.toml como parte de los metadatos del proyecto. Después, hablaré de las dependencias de desarrollo y de los archivos de requisitos. Por último, explicaré cómo puedes bloquear dependencias a versiones precisas para conseguir implementaciones fiables y comprobaciones repetibles.

Añadir dependencias a la aplicación de ejemplo

Como ejemplo de trabajo, vamos a mejorar random-wikipedia-article delEjemplo 3-1 con la biblioteca HTTPX, un cliente HTTP con todas las funciones que admite tanto solicitudes síncronas como asíncronas, así como la versión más reciente (y mucho más eficiente) del protocolo HTTP/2. También mejorarás la salida del programa utilizandoRich, una biblioteca para texto enriquecido y formato bonito en el terminal.

Consumir una API con HTTPX

Wikipedia pide a los desarrolladores que establezcan una cabecera User-Agent con datos de contacto. No es para que puedan enviar postales para felicitar a la gente por su hábil uso de la API de Wikipedia. Es una forma de ponerse en contacto con ellos en caso de que un cliente, inadvertidamente, dañe sus servidores.

El Ejemplo 4-1 muestra cómo puedes utilizar httpx para enviar una solicitud a la API de Wikipedia con la cabecera. También podrías utilizar la biblioteca estándar para enviar una cabecera User-Agent con tus peticiones. Pero httpx ofrece una interfaz más intuitiva, explícita y flexible, incluso cuando no utilices ninguna de sus funciones avanzadas.

Ejemplo 4-1. Utilizar httpx para consumir la API de Wikipedia
import textwrap
import httpx

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary"
USER_AGENT = "random-wikipedia-article/0.1 (Contact: you@example.com)"

def main():
    headers = {"User-Agent": USER_AGENT}

    with httpx.Client(headers=headers) as client: 1
        response = client.get(API_URL, follow_redirects=True) 2
        response.raise_for_status() 3
        data = response.json() 4

    print(data["title"], end="\n\n")
    print(textwrap.fill(data["extract"]))
1

Al crear una instancia de cliente, puedes especificar las cabeceras que debe enviar con cada solicitud, como la cabecera User-Agent. Utilizar el cliente como gestor de contexto garantiza que la conexión de red se cierre al final del bloque with.

2

Esta línea realiza dos solicitudes HTTP GET a la API. La primera va al punto final aleatorio, que responde con una redirección al artículo real. La segunda sigue a la redirección.

3

El método raise_for_status lanza una excepción si la respuesta del servidor indica un error a través de su código de estado.

4

El método json abstrae los detalles de analizar el cuerpo de la respuesta como JSON.

Salida de la consola con Rich

De paso, mejoremos el aspecto del programa en .El Ejemplo 4-2 utiliza Rich, una biblioteca para la salida por consola, para mostrar el título del artículo en negrita. Esto apenas roza la superficie de las opciones de formato de Rich. Los terminales modernos son sorprendentemente capaces, y Rich te permite aprovechar su potencial con facilidad. Echa un vistazo a sudocumentación oficial para más detalles.

Ejemplo 4-2. Utilizar Rich para mejorar la salida de la consola
import httpx
from rich.console import Console

def main():
    ...
    console = Console(width=72, highlight=False) 1
    console.print(data["title"], style="bold", end="\n\n") 2
    console.print(data["extract"])
1

Los objetos Consola proporcionan un método print para la salida de la consola. Establecer el ancho de la consola en 72 caracteres sustituye a nuestra anterior llamada atextwrap.fill. También querrás desactivar el resaltado automático de sintaxis, ya que estás formateando prosa en lugar de datos o código.

2

La palabra clave style te permite distinguir el título utilizando una fuente en negrita.

Especificar dependencias para un proyecto

Si aún no lo has hecho, crea y activa un entorno virtual para el proyecto, y realiza una instalación editable desde el directorio actual:

$ uv venv
$ uv pip install --editable .

Puedes tener la tentación de instalar httpx y rich manualmente en el entorno. En lugar de eso, añádelos a las dependencias del proyecto en pyproject.toml. Esto garantiza que siempre que instales tu proyecto, los dos paquetes se instalen junto con él:

[project]
name = "random-wikipedia-article"
version = "0.1"
dependencies = ["httpx", "rich"]
...

Si reinstalas el proyecto, verás que uv instala también sus dependencias:

$ uv pip install --editable .

Cada entrada del campo dependencies es una especificación de dependencia. Además del nombre del paquete, te permite proporcionar información adicional: especificadores de versión, extras y marcadores de entorno. Las siguientes secciones de explican qué son.

Especificadores de versión

Los especificadores de versión definen el rango de versiones aceptables para un paquete. Cuando añadas una nueva dependencia, es una buena idea incluir su versión actual como límite inferior, a menos que tu proyecto necesite ser compatible con versiones anteriores. Actualiza el límite inferior siempre que empieces a depender de características más recientes del paquete:

[project]
dependencies = ["httpx>=0.27.0", "rich>=13.7.1"]

¿Por qué declarar límites inferiores en tus dependencias? Los instaladores eligen por defecto la última versión para una dependencia. Hay tres razones por las que debería importarte. En primer lugar, las bibliotecas suelen instalarse junto a otros paquetes, que pueden tener restricciones de versión adicionales. En segundo lugar, ni siquiera las aplicaciones se instalan siempre de forma aislada; por ejemplo, las distribuciones de Linux pueden empaquetar tu aplicación para el entorno de todo el sistema. En tercer lugar, los límites inferiores te ayudan a detectar conflictos de versión en tu propio árbol de dependencias, como cuando necesitas una versión reciente de un paquete, pero otra dependencia sólo funciona con sus versiones anteriores.

Evita los límites superiores de versión especulativos: no deberías protegerte de nuevas versiones a menos que sepas que son incompatibles con tu proyecto. Consulta"Límites superiores de versión en Python" sobre los problemas con la limitación de versiones.

Los archivos de bloqueo son una solución mucho mejor que los límites superiores para las roturas inducidas por las dependencias: solicitan versiones "buenas conocidas" de tus dependencias al desplegar un servicio o ejecutar comprobaciones automatizadas (consulta"Bloquear dependencias").

Si una versión estropeada rompe tu proyecto, publica una versión con corrección de errores para excluir esa versión estropeada concreta:

[project]
dependencies = ["awesome>=1.2,!=1.3.1"]

Utiliza un límite superior como último recurso si una dependencia rompe la compatibilidad de forma permanente. Levanta el límite de versión cuando puedas adaptar tu código:

[project]
dependencies = ["awesome>=1.2,<2"]
Advertencia

Excluir versiones a posteriori tiene una trampa de la que debes ser consciente. Los solucionadores de dependencias pueden decidir degradar tu proyecto a una versión sin la exclusión y actualizar la dependencia de de todos modos. Los archivos de bloqueo pueden ayudar con esto.

Los especificadores de versión admiten varios operadores, como se muestra enla Tabla 4-1. En resumen, utiliza los operadores de igualdad y comparación que conoces de Python: ==, !=, <=, >=, <, y >.

Tabla 4-1. Especificadores de versión
Operario Nombre Descripción

==

Coincidencia de versiones

Las versiones deben ser iguales después de la normalización. Se eliminan los ceros finales.

!=

Exclusión de la versión

La inversa del operador ==

<=, >=

Comparación ordenada inclusiva

Realiza una comparación lexicográfica. Las versiones preliminares preceden a las definitivas.

<, >

Comparación ordenada exclusiva

Similar a inclusivo, pero las versiones no deben compararse igual

~=

Liberación compatible

Equivale a >=x.y,==x.* con la precisión especificada

===

Igualdad arbitraria

Comparación simple de cadenas para versiones no estándar

Tres operadores merecen un debate adicional:

  • El operador == admite comodines (*), aunque sólo al final de la cadena de versión. En otras palabras, puedes exigir que la versión coincida con un prefijo concreto, como 1.2.*.

  • El operador === te permite realizar una simple comparación carácter a carácter. Es mejor utilizarlo como último recurso para versiones no estándar.

  • El operador ~= para versiones compatibles especifica que la versión debe ser mayor o igual que el valor dado, sin dejar de empezar por el mismo prefijo. Por ejemplo, ~=1.2.3 es equivalente a >=1.2.3,==1.2.*, y ~=1.2es equivalente a >=1.2,==1.*.

No necesitas protegerte de las versiones preliminares: los especificadores de versión las excluyen por defecto. Las versiones preliminares sólo son candidatas válidas en tres situaciones: cuando ya están instaladas, cuando no hay otras versiones que satisfagan la especificación de dependencia, y cuando las solicitas explícitamente, utilizando una cláusula como>=1.0.0rc1.

Extras

Supongamos que quieres utilizar el nuevo protocolo HTTP/2 con httpx. Esto sólo requiere un pequeño cambio en el código que crea el cliente HTTP:

def main():
    headers = {"User-Agent": USER_AGENT}
    with httpx.Client(headers=headers, http2=True) as client:
        ...

Bajo el capó, httpx delega los detalles escabrosos de hablar HTTP/2 a otro paquete, h2. Sin embargo, esa dependencia no se activa por defecto. De este modo, los usuarios que no necesiten el nuevo protocolo se ahorran un árbol de dependencias más pequeño. Tú sí lo necesitas aquí, así que activa la función opcional utilizando la sintaxishttpx[http2]:

[project]
dependencies = ["httpx[http2]>=0.27.0", "rich>=13.7.1"]

Las funciones opcionales que requieren dependencias adicionales se conocen como extras, y puedes tener más de una. Por ejemplo, puedes especificarhttpx[http2,brotli] para permitir la descodificación de respuestas con compresión Brotli, que es un algoritmo de compresión desarrollado en Google que es habitual en servidores web y redes de distribución de contenidos.

Dependencias opcionales

Veamos esta situación de desde el punto de vista de httpx. Las dependencias de h2y brotli son opcionales, por lo que httpx las declara enoptional-dependencies en lugar de dependencies(Ejemplo 4-3).

Ejemplo 4-3. Dependencias opcionales de httpx (simplificado)
[project]
name = "httpx"

[project.optional-dependencies]
http2 = ["h2>=3,<5"]
brotli = ["brotli"]

El campo optional-dependencies es una tabla TOML. Puede contener varias listas de dependencias, una por cada extra que proporcione el paquete. Cada entrada es una especificación de dependencia y utiliza las mismas reglas que el campo dependencies.

Si añades una dependencia opcional a tu proyecto, ¿cómo la utilizas en tu código? No compruebes si tu paquete se instaló con el extra: simplemente importa el paquete opcional. Puedes capturar la excepción ImportError si el usuario no solicitó el extra:

try:
    import h2
except ImportError:
    h2 = None

# Check h2 before use.
if h2 is not None:
    ...

Se trata de un patrón habitual en Python, tan habitual que tiene nombre y acrónimo: "Es más fácil pedir perdón que permiso" (EAFP). Su contrapartida menos pitónica se denomina "Mira antes de saltar" (LBYL).

Marcadores de entorno

La tercera pieza de metadatos que puedes especificar para una dependencia son los marcadores de entorno. Antes de explicarte qué son estos marcadores, déjame mostrarte un ejemplo en el que resultan útiles.

Si has mirado la cabecera User-Agent enel Ejemplo 4-1 y has pensado: "No debería tener que repetir el número de versión en el código", tienes toda la razón. Como viste en"Obtención única de la versión del proyecto", puedes leer la versión de tu paquete a partir de sus metadatos en el entorno.

El ejemplo 4-4 muestra cómo puedes utilizar la función importlib.metadata.metadata para construir la cabecera User-Agent a partir de los campos de metadatos principales Name, Version y Author-email. Estos campos corresponden a name, version, y authors en los metadatos del proyecto.3

Ejemplo 4-4. Utilizar importlib.metadata para construir una cabecera User-Agent
from importlib.metadata import metadata

USER_AGENT = "{Name}/{Version} (Contact: {Author-email})"

def build_user_agent():
    fields = metadata("random-wikipedia-article") 1
    return USER_AGENT.format_map(fields) 2

def main():
    headers = {"User-Agent": build_user_agent()}
    ...
1

La función metadata recupera los campos de metadatos principales del paquete.

2

La función str.format_map busca cada marcador de posición en el mapeo.

La biblioteca importlib.metadata se introdujo en Python 3.8. Aunque ahora está disponible en todas las versiones compatibles, no siempre fue así. ¿No tenías suerte si tenías que soportar una versión anterior de Python?

No del todo. Afortunadamente, muchas adiciones a la biblioteca estándar vienen conbackports-paquetes de tercerosque proporcionan la funcionalidad para intérpretes antiguos. Para importlib.metadata, puedes recurrir al backportimportlib-metadata de PyPI. El backport sigue siendo útil porque la biblioteca cambió varias veces después de su introducción.

Sólo necesitas backports en entornos que utilicen versiones específicas de Python. Un marcador de entorno te permite expresar esto como una dependencia condicional:

importlib-metadata; python_version < '3.8'

Los instaladores sólo instalarán el paquete en intérpretes anteriores a Python 3.8.

De forma más general, un marcador de entorno expresa una condición que debe satisfacer un entorno para que se aplique la dependencia. Los instaladores evalúan la condición en el intérprete del entorno de destino.

Los marcadores de entorno te permiten solicitar dependencias para sistemas operativos específicos, arquitecturas de procesador, implementaciones de Python o versiones de Python.La Tabla 4-2 enumera todos los marcadores de entorno a tu disposición, tal y como se especifica en la PEP 508.4

Tabla 4-2. Marcadores de entornoa
Marcador medioambiental Biblioteca estándar Descripción Ejemplos

os_name

os.name()

La familia de sistemas operativos

posix, nt

sys_platform

sys.platform()

El identificador de la plataforma

linux, darwin, win32

platform_system

platform.system()

El nombre del sistema

Linux, Darwin, Windows

platform_release

platform.release()

La versión del sistema operativo

23.2.0

platform_version

platform.version()

La liberación del sistema

Darwin Kernel Version 23.2.0: ...

platform_machine

platform.machine()

La arquitectura del procesador

x86_64, arm64

python_version

platform.python​_ver⁠sion_tuple()

La versión de la función Python en el formato x.y

3.12

python_full_version

platform.python​_ver⁠sion()

La versión completa de Python

3.12.0, 3.13.0a4

platform_python​_imple⁠mentation

platform.python​_imple⁠mentation()

La implementación de Python

CPython, PyPy

implementation_name

sys.implementa⁠tion​.name

La implementación de Python

cpython, pypy

implementation​_ver⁠sion

sys.implementation​.ver⁠sion

La versión de implementación de Python

3.12.0, 3.13.0a4

a Los marcadores python_version y implementation_version aplican transformaciones. Consulta la PEP 508 para más detalles.

Volviendo al Ejemplo 4-4, aquí tienes los campos requires-python y dependencies para que el paquete sea compatible con Python 3.7:

[project]
requires-python = ">=3.7"
dependencies = [
    "httpx[http2]>=0.24.1",
    "rich>=13.7.1",
    "importlib-metadata>=6.7.0; python_version < '3.8'",
]

El nombre de importación del backport es importlib_metadata, mientras que el módulo de la biblioteca estándar se llama importlib.metadata. Puedes importar el módulo apropiado en tu código comprobando la versión de Python en sys.version_info:

if sys.version_info >= (3, 8):
    from importlib.metadata import metadata
else:
    from importlib_metadata import metadata

¿Acabo de oír a alguien gritar "EAFP"? Si tus importaciones de dependen de la versión de Python, es mejor evitar la técnica de"Dependencias opcionales" y "mirar antes de saltar". Una comprobación explícita de la versión comunica tu intención a los analizadores estáticos, como o el comprobador de tipos mypy (ver Capítulo 10). EAFP puede dar lugar a errores de estas herramientas porque no pueden detectar cuándo está disponible cada módulo.

Los marcadores admiten los mismos operadores de igualdad y comparación que los especificadores de versión(Tabla 4-1). Además, puedes utilizar in ynot in para comparar una subcadena con el marcador. Por ejemplo, la expresión'arm' in platform_version comprueba si platform.version() contiene la cadena'arm'.

También puedes combinar varios marcadores utilizando los operadores booleanos and yor. He aquí un ejemplo bastante rebuscado que combina todas estas funciones:

[project]
dependencies = ["""                                                         \
  awesome-package; python_full_version <= '3.8.1'                           \
    and (implementation_name == 'cpython' or implementation_name == 'pypy') \
    and sys_platform == 'darwin'                                            \
    and 'arm' in platform_version                                           \
"""]

El ejemplo también se basa en el soporte de TOML para cadenas multilínea, que utiliza comillas triples igual que Python. Las especificaciones de dependencia no pueden abarcar varias líneas, por lo que tienes que escapar las nuevas líneas con una barra invertida.

Dependencias de desarrollo

Las dependencias de desarrollo son paquetes de terceros que necesitas durante el desarrollo. Como desarrollador, es posible que utilices el marco de pruebas pytest para ejecutar el conjunto de pruebas de tu proyecto, el sistema de documentación Sphinx para construir sus documentos, o una serie de otras herramientas para ayudar en el mantenimiento del proyecto. Tus usuarios, en cambio, no necesitan instalar ninguno de estos paquetes para ejecutar tu código.

Un ejemplo: Pruebas con pytest

Como ejemplo concreto, vamos a añadir una pequeña prueba para la función build_user_agentdel Ejemplo 4-4. Crea un directorio tests con dos archivos: un __init__.py vacío y un módulotest_random_wikipedia_article.py con el código delEjemplo 4-5.

Ejemplo 4-5. Prueba de la cabecera User-Agent generada
from random_wikipedia_article import build_user_agent

def test_build_user_agent():
    assert "random-wikipedia-article" in build_user_agent()

El Ejemplo 4-5 sólo utiliza funciones incorporadas de Python, por lo que podrías simplemente importar y ejecutar la prueba manualmente. Pero incluso para esta pequeña prueba, pytest añade tres funciones útiles. En primer lugar, descubre módulos y funciones cuyos nombres empiezan por test, por lo que puedes ejecutar tus pruebas invocando apytest sin argumentos. En segundo lugar, pytest muestra las pruebas a medida que las ejecuta, así como un resumen con los resultados de la prueba al final. En tercer lugar, pytest reescribe las aserciones de tus pruebas para darte mensajes amistosos e informativos cuando fallan.

Vamos a ejecutar la prueba con pytest. Asumo que ya tienes un entorno virtual activo con una instalación editable de tu proyecto. Introduce los siguientes comandos para instalar y ejecutar pytest en ese entorno:

$ uv pip install pytest
$ py -m pytest
========================= test session starts ==========================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0
rootdir: ...
plugins: anyio-4.3.0
collected 1 item

tests/test_random_wikipedia_article.py .                          [100%]

========================== 1 passed in 0.22s ===========================

Por ahora, las cosas pintan muy bien. Las pruebas ayudan a que tu proyecto evolucione sin romper cosas. La prueba para build_user_agent es un primer paso en esa dirección. Instalar y ejecutar pytest es un pequeño coste de infraestructura comparado con estos beneficios a largo plazo.

Configurar el entorno de un proyecto se vuelve más difícil a medida que adquieres más dependencias de desarrollo: generadores de documentación, linters, formateadores de código, comprobadores de tipos u otras herramientas. Incluso tu conjunto de pruebas puede requerir algo más que pytest: plugins para pytest, herramientas para medir la cobertura del código, o simplemente paquetes que te ayuden a ejercitar tu código.

También necesitas versiones compatibles de estos paquetes: tu conjunto de pruebas puede requerir la última versión de pytest, mientras que tu documentación puede no construirse con la nueva versión de Sphinx. Cada uno de tus proyectos de puede tener requisitos ligeramente diferentes. Multiplica esto por el número de desarrolladores que trabajan en cada proyecto, y queda claro que necesitas una forma de rastrear tus dependencias de desarrollo.

En el momento de escribir esto, Python no tiene una forma estándar de declarar las dependencias de desarrollo de un proyecto, aunque muchos gestores de proyectos Python las admiten en su tabla [tool] y existe un borrador PEP.5 Además de los gestores de proyectos, la gente utiliza dos enfoques para llenar el vacío: dependencias opcionales y archivos de requisitos.

Dependencias opcionales

Como has visto en "Extras ", la tabla optional-dependenciescontiene grupos de dependencias opcionales denominados extras. Tiene tres propiedades que la hacen adecuada para el seguimiento de dependencias de desarrollo. En primer lugar, los paquetes no se instalan por defecto, para que los usuarios finales no contaminen su entorno Python con ellos. En segundo lugar, te permite agrupar los paquetes bajo nombres significativos como tests o docs. Y en tercer lugar, el campo viene con toda la expresividad de las especificaciones de dependencia, incluidas las restricciones de versión y los marcadores de entorno.

Por otra parte, hay un desajuste de impedancia entre las dependencias de desarrollo y las dependencias opcionales. Las dependencias opcionales se exponen a los usuarios a través de los metadatos del paquete: permiten a los usuarios optar a funciones que requieren paquetes adicionales. En cambio, no se pretende que los usuarios instalen dependencias de desarrollo: estos paquetes no son necesarios para ninguna función orientada al usuario.

Además, no puedes instalar extras sin el propio proyecto. En cambio, no todas las herramientas para desarrolladores necesitan tener instalado tu proyecto. Por ejemplo, los linters analizan tu código fuente en busca de errores y posibles mejoras. Puedes ejecutarlos en un proyecto sin instalarlo en el entorno. Además de perder tiempo y espacio, los entornos "gordos" limitan innecesariamente la resolución de dependencias. Por ejemplo, muchos proyectos Python ya no podían actualizar dependencias importantes cuando el linter Flake8 puso un tope de versión en importlib-metadata.

Teniendo esto en cuenta, los extras se utilizan mucho para las dependencias de desarrollo y son el único método cubierto por una norma de empaquetado. Son una opción pragmática, sobre todo si gestionas linters con pre-commit (ver Capítulo 9).El Ejemplo 4-6 muestra cómo utilizarías extras para hacer un seguimiento de los paquetes necesarios para las pruebas y la documentación.

Ejemplo 4-6. Utilizar extras para representar dependencias de desarrollo
[project.optional-dependencies]
tests = ["pytest>=7.4.4", "pytest-sugar>=1.0.0"] 1
docs = ["sphinx>=5.3.0"] 2
1

El plugin pytest-sugar mejora la salida de de pytest con una barra de progreso y muestra los fallos inmediatamente.

2

Sphinx es un generador de documentación utilizado por la documentación oficial de Python y muchos proyectos de código abierto.

Ahora puedes instalar las dependencias de prueba utilizando el extra tests:

$ uv pip install -e ".[tests]"
$ py -m pytest

También puedes definir un dev extra con todas las dependencias de desarrollo. Esto te permite configurar un entorno de desarrollo de una sola vez, con tu proyecto y todas las herramientas que utiliza:

$ uv pip install -e ".[dev]"

No es necesario repetir todos los paquetes cuando defines dev. En lugar de eso, puedes simplemente hacer referencia a los otros extras, como se muestra en elEjemplo 4-7.

Ejemplo 4-7. Proporcionar un dev extra con todas las dependencias de desarrollo
[project.optional-dependencies]
tests = ["pytest>=7.4.4", "pytest-sugar>=1.0.0"]
docs = ["sphinx>=5.3.0"]
dev = ["random-wikipedia-article[tests,docs]"]

Este estilo de declarar un extra también se conoce como dependencia opcional recursiva, ya que el paquete con el extra dev depende de sí mismo (con los extrastests y docs ).

Archivos de requisitos

Los archivos de requisitos son archivos de texto plano con especificaciones de dependencia en cada línea(Ejemplo 4-8). Además, pueden contener URLs y rutas, opcionalmente prefijadas por -e para una instalación editable, así como opciones globales, como -r para incluir otro archivo de requisitos o--index-url para utilizar un índice de paquetes distinto de PyPI. El formato de archivo también admite comentarios al estilo Python (con un carácter inicial # ) y continuaciones de línea (con un carácter final \ ).

Ejemplo 4-8. Un simple archivo requirements.txt
pytest>=7.4.4
pytest-sugar>=1.0.0
sphinx>=5.3.0

Puedes instalar las dependencias enumeradas en en un archivo de requisitos utilizando pip o uv:

$ uv pip install -r requirements.txt

Por convención, un archivo de requisitos se llama requisitos.txt. Sin embargo, las variaciones son habituales. Puedes tener un dev-requisitos.txt para las dependencias de desarrollo o un directorio de requisitos con un archivo por grupo de dependencias(Ejemplo 4-9).

Ejemplo 4-9. Utilizar archivos de requisitos para especificar dependencias de desarrollo
# requirements/tests.txt
-e . 1
pytest>=7.4.4
pytest-sugar>=1.0.0

# requirements/docs.txt
sphinx>=5.3.0 2

# requirements/dev.txt
-r tests.txt 3
-r docs.txt
1

El archivo tests.txt requiere una instalación editable del proyecto porque el conjunto de pruebas necesita importar los módulos de la aplicación.

2

El archivo docs.txt no requiere el proyecto. (Eso suponiendo que construyas la documentación sólo a partir de archivos estáticos. Si utilizas la extensión autodoc Sphinx para generar la documentación de la API a partir de docstrings en tu código, también necesitarás el proyecto aquí).

3

El archivo dev.txt incluye los demás archivos de requisitos.

Nota

Si incluyes otros archivos de requisitos utilizando -r, sus rutas se evalúan en relación al archivo de inclusión. En cambio, las rutas a las dependencias se evalúan en relación a tu directorio actual, que suele ser el directorio del proyecto.

Crea y activa un entorno virtual y, a continuación, ejecuta los siguientes comandos para instalar las dependencias de desarrollo y ejecutar el conjunto de pruebas:

$ uv pip install -r requirements/dev.txt
$ py -m pytest

Los archivos de requisitos no forman parte de los metadatos del proyecto. Los compartes con otros desarrolladores mediante el sistema de control de versiones, pero son invisibles para tus usuarios. Para las dependencias de desarrollo, esto es exactamente lo que quieres. Además, los archivos de requisitos no incluyen implícitamente tu proyecto en las dependencias, lo que ahorra tiempo a todas las tareas que no necesitan el proyecto instalado.

Los archivos de requisitos también tienen desventajas. No son un estándar de empaquetado y es improbable que lleguen a serlo: cada línea de un archivo de requisitos es esencialmente un argumento para pip install. "Lo que pip haga" puede seguir siendo la ley no escrita para muchos casos perimetrales en el empaquetado de Python, pero los estándares de la comunidad la sustituyen cada vez más. Otro inconveniente es el desorden que estos archivos causan en tu proyecto en comparación con una tabla en pyproject.toml.

Como ya se ha mencionado, los gestores de proyectos de Python te permiten declarar dependencias de desarrollo enpyproject.toml, fuera de los metadatos del proyecto -Rye, Hatch, PDM y Poetry ofrecen esta función. Consulta el Capítulo 5 para ver una descripción de los grupos de dependencias de Poetry.

Bloquear dependencias

Has instalado tus dependencias en un entorno local o en integración continua (IC), y has ejecutado tu conjunto de pruebas y cualquier otra comprobación que tengas preparada. Todo tiene buena pinta, y estás listo para implementar tu código. Pero, ¿cómo instalas en producción los mismos paquetes que utilizaste al ejecutar tus comprobaciones?

Utilizar paquetes diferentes en desarrollo y en producción tiene consecuencias. La producción puede acabar con un paquete incompatible con tu código, que tenga un error o una vulnerabilidad de seguridad o, en el peor de los casos, que haya sido secuestrado por un atacante. Si tu servicio está muy expuesto, este escenario es preocupante, y puede afectar a cualquier paquete de tu árbol de dependencias, no sólo a los que importas directamente.

Advertencia

Los ataques a la cadena de suministro se infiltran en un sistema dirigiéndose a sus dependencias de terceros. Por ejemplo, en 2022, un actor de amenazas apodado "JuiceLedger" subió paquetes maliciosos a proyectos PyPI legítimos tras comprometerlos con una campaña de phishing.6

Hay muchas razones por las que los entornos acaban con paquetes diferentes dadas las mismas especificaciones de dependencia. La mayoría de ellas se dividen en dos categorías: cambios en el flujo ascendente y desajuste del entorno. En primer lugar, puedes obtener paquetes diferentes si el conjunto de paquetes disponibles cambia aguas arriba:

  • Llega una nueva versión antes de que la implementes.

  • Se sube un nuevo artefacto para una versión existente. Por ejemplo, los mantenedores a veces suben ruedas adicionales cuando sale una nueva versión de Python.

  • Un mantenedor borra o yanks una versión o artefacto de . El yanking es un borrado suave que oculta el archivo de la resolución de dependencias a menos que lo solicites específicamente.

En segundo lugar, puedes obtener paquetes diferentes si tu entorno de desarrollo no coincide con el entorno de producción:

  • Losmarcadores de entorno se evalúan de forma diferente en el intérprete de destino (ver"Marcadores de entorno"). Por ejemplo, el entorno de producción puede utilizar una versión antigua de Python que requiera un backport comoimportlib-metadata.

  • Las etiquetas de compatibilidad de ruedas pueden hacer que el instalador seleccione una rueda diferente para el mismo paquete (consulta "Etiquetas de compatibilidad de ruedas"). Por ejemplo, esto puede ocurrir si desarrollas en un Mac con silicio Apple mientras que en producción se utiliza Linux en una arquitectura x86-64.

  • Si la versión no incluye una rueda para el entorno de destino, el instalador la crea sobre la marcha a partir de la sdist. Las ruedas para los módulos de extensión suelen retrasarse cuando una nueva versión de Python ve la luz.

  • Si los entornos no utilizan el mismo instalador (o versiones diferentes del mismo instalador), cada instalador puede resolver las dependencias de forma diferente. Por ejemplo, uv utiliza el algoritmo PubGrub para la resolución de dependencias,7 mientras que pip utiliza un resolutor de retroceso para los paquetes de Python, resolvelib.

  • La configuración o el estado de las herramientas también pueden provocar resultados distintos: por ejemplo, puedes instalar desde un índice de paquetes distinto o desde una caché local.

Necesitas una forma de definir el conjunto exacto de paquetes que necesita tu aplicación, y quieres que su entorno sea una imagen exacta de este inventario de paquetes. Este proceso se conoce como bloquear, o fijar, las dependencias del proyecto, que se enumeran en un archivo de bloqueo.

Hasta ahora, he hablado del bloqueo de dependencias para conseguir implementaciones fiables y reproducibles. El bloqueo también es beneficioso durante el desarrollo, tanto para las aplicaciones como para las bibliotecas. Al compartir un archivo de bloqueo con tu equipo y con los colaboradores, pones a todo el mundo en la misma página: cada desarrollador utiliza las mismas dependencias cuando ejecuta el conjunto de pruebas, construye la documentación o realiza otras tareas. Utilizar el archivo de bloqueo para las comprobaciones obligatorias evita sorpresas cuando las comprobaciones fallan en CI después de pasarlas localmente. Para aprovechar estas ventajas, los archivos de bloqueo deben incluir también las dependencias de desarrollo.

En el momento de escribir esto, Python carece de un estándar de empaquetado para los archivos de bloqueo, aunque el tema se está estudiando activamente.8 Mientras tanto, muchos gestores de proyectos Python, como Poetry, PDM y pipenv, han implementado en sus propios formatos de archivos de bloqueo; otros, como Rye, utilizan archivos de requisitos para bloquear dependencias.

En esta sección, presentaré dos métodos para bloquear dependencias utilizando archivos de requisitos: congelar y compilar requisitos. Enel Capítulo 5, describiré los archivos de bloqueo de Poesía.

Congelación Requisitos con pip y uv

Los archivos de requisitos son un formato popular para las dependencias de bloqueo de . Te permiten mantener la información sobre las dependencias separada de los metadatos del proyecto. Pip y uv pueden generar estos archivos a partir de un entorno existente:

$ uv pip install .
$ uv pip freeze
anyio==4.3.0
certifi==2024.2.2
h11==0.14.0
h2==4.1.0
hpack==4.0.0
httpcore==1.0.4
httpx==0.27.0
hyperframe==6.0.1
idna==3.6
markdown-it-py==3.0.0
mdurl==0.1.2
pygments==2.17.2
random-wikipedia-article @ file:///Users/user/random-wikipedia-article
rich==13.7.1
sniffio==1.3.1

Hacer un inventario de los paquetes instalados en un entorno se conoce comocongelación. Guarda la lista en requirements.txt y confirma el archivo en el control de código fuente, con un cambio: sustituye la URL del archivo por un punto para el directorio actual. Esto te permite utilizar el archivo de requisitos en cualquier lugar, siempre que estés dentro del directorio del proyecto.

Cuando despliegues tu proyecto en producción, puedes instalar el proyecto y sus dependencias de la siguiente manera:

$ uv pip install -r requirements.txt

Suponiendo que tu entorno de desarrollo utilice un intérprete reciente de , el archivo de requisitos no incluirá importlib-metadata-esa biblioteca sólo es necesaria antes de Python 3.8. Si tu entorno de producción ejecuta una versión antigua de Python, tu implementación se romperá. Aquí hay una lección importante: bloquea tus dependencias en un entorno que coincida con el de producción.

Consejo

Bloquea tus dependencias en la misma versión de Python, implementación de Python, sistema operativo y arquitectura de procesador que los utilizados en producción. Si realizas la implementación en varios entornos, genera un archivo de requisitos para cada uno de ellos.

Congelar requisitos tiene algunas limitaciones. En primer lugar, tienes que instalar tus dependencias cada vez que actualices el archivo de requisitos. En segundo lugar, es fácil contaminar inadvertidamente el archivo de requisitos si instalas temporalmente un paquete y olvidas crear después el entorno desde cero.9 En tercer lugar, la congelación no te permite registrar los hashes de los paquetes: sólo hace un inventario de un entorno, y los entornos no registran los hashes de los paquetes que instalas en ellos. (Trataré los hashes de paquetes en la siguiente sección).

Compilar requisitos con pip-tools y uv

El proyecto pip-tools te permite bloquear dependencias sin estas limitaciones. Puedes compilar los requisitos directamente desde pyproject.toml, sin instalar los paquetes. Bajo el capó, pip-tools aprovecha pip y su resolvedor de dependencias.

Pip-tools viene con dos comandos: pip-compile , para crear un archivo de requisitos a partir de especificaciones de dependencia, y pip-sync, para aplicar el archivo de requisitos a un entorno existente. La herramienta uv proporciona sustitutos directos para ambos comandos: uv pip compile y uv pip sync.

Ejecuta pip-compile en un entorno que coincida con el entorno objetivo de tu proyecto. Si utilizas pipx, especifica en la versión de Python de destino:

$ pipx run --python=3.12 --spec=pip-tools pip-compile

Por defecto, pip-compile lee de pyproject.toml y escribe enrequirements.txt. Puedes utilizar la opción --output-file para especificar un destino diferente. La herramienta también imprime los requisitos en el error estándar, a menos que especifiques --quiet para desactivar la salida del terminal.

Uv requiere que seas explícito sobre los archivos de entrada y salida:

$ uv pip compile --python-version=3.12 pyproject.toml -o requirements.txt

Pip-tools y uv anotan el archivo para indicar el paquete dependiente de cada dependencia, así como el comando utilizado para generar el archivo. Hay una diferencia más en la salida de pip freeze: los requisitos compilados no incluyen tu propio proyecto. Tendrás que instalarlo por separado después de aplicar el archivo de requisitos.

Los archivos de requisitos te permiten especificar los hashes de los paquetes para cada dependencia. Estos hashes añaden otra capa de seguridad a tus implementaciones: te permiten instalar en producción sólo artefactos de empaquetado verificados. La opción--generate-hashes incluye hashes SHA-256 para cada paquete enumerado en el archivo de requisitos. Por ejemplo, aquí tienes los hashes sobre los archivos sdist y wheel de una versión httpx:

httpx==0.27.0 \
--hash=sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5 \
--hash=sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5

Los hashes de paquetes hacen que las instalaciones sean más deterministas y reproducibles. También son una herramienta importante en las organizaciones que exigen examinar cada artefacto que entra en producción. Validar la integridad de los paquetes evitalos ataques en ruta, en los que un actor de la amenaza ("man in the middle") intercepta la descarga de un paquete para suministrar un artefacto comprometido.

Los hashes también tienen el efecto secundario de que pip se niega a instalar paquetes sin ellos: o todos los paquetes tienen hashes, o ninguno los tiene. Como consecuencia, los hashes te protegen de la instalación de archivos que no figuran en el archivo de requisitos.

Instala el archivo de requisitos en el entorno de destino utilizando pip o uv, seguido del propio proyecto. Puedes endurecer la instalación utilizando un par de opciones: la opción --no-deps garantiza que sólo se instalen los paquetes enumerados en el archivo de requisitos, y la opción --no-cache impide que el instalador reutilice los artefactos descargados o construidos localmente:

$ uv pip install -r requirements.txt
$ uv pip install --no-deps --no-cache .

Actualiza tus dependencias a intervalos regulares en . Una vez a la semana puede ser aceptable para una aplicación madura en producción. Diariamente puede ser más apropiado para un proyecto en desarrollo activo, o incluso tan pronto como lleguen las versiones. Herramientas como Dependabot y Renovate ayudan a con esta tarea: abren pull requests en tus repositorios con actualizaciones automáticas de dependencias.

Si no actualizas las dependencias con regularidad, puedes verte obligado a aplicar una actualización "big bang" bajo la presión del tiempo. Una sola vulnerabilidad de seguridad puede obligarte a portar tu proyecto a versiones mayores de múltiples paquetes, así como del propio Python.

Puedes actualizar tus dependencias todas a la vez, o una dependencia cada vez. Utiliza la opción --upgrade para actualizar todas las dependencias a su última versión, o pasa un paquete específico con la opción --upgrade-package (-P).

Por ejemplo, así es como actualizarías a Rich a la última versión:

$ uv pip compile -p 3.12 pyproject.toml -o requirements.txt -P rich

Hasta ahora, has creado el entorno de destino desde cero. También puedes utilizarpip-sync para sincronizar el entorno de destino con el archivo de requisitos actualizado. No instales pip-tools en el entorno de destino para esto: sus dependencias pueden entrar en conflicto con las de tu proyecto. En su lugar, utiliza pipx, como hiciste con pip-compile. Apunta pip-sync al intérprete de destino utilizando su opción--python-executable:

$ py -m venv .venv
$ pipx run --spec=pip-tools pip-sync --python-executable=.venv/bin/python

El comando elimina el proyecto en sí, ya que no figura en el archivo de requisitos. Reinstálalo después de sincronizar:

$ .venv/bin/python -m pip install --no-deps --no-cache .

Uv utiliza por defecto el entorno en .venv, por lo que puedes simplificar estos comandos:

$ uv pip sync requirements.txt
$ uv pip install --no-deps --no-cache .

En "Dependencias de desarrollo", viste dos formas de declarar dependencias de desarrollo: extras y archivos de requisitos. Pip-tools y uv admiten ambas como entradas. Si realizas un seguimiento de las dependencias de desarrollo en un extra de dev, genera el archivodev-requirements.txt de la siguiente manera:

$ uv pip compile --extra=dev pyproject.toml -o dev-requirements.txt

Si tienes extras de grano más fino, el proceso es el mismo. Puede que quieras almacenar los archivos de requisitos en un directorio de requisitos para evitar el desorden.

Si especificas tus dependencias de desarrollo en archivos de requisitos en lugar de extras, compila cada uno de estos archivos sucesivamente. Por convención, los requisitos de entrada utilizan la extensión .in, mientras que los requisitos de salida utilizan la extensión .txt(Ejemplo 4-10).

Ejemplo 4-10. Requisitos de entrada para las relaciones de desarrollo
# requirements/tests.in
pytest>=7.4.4
pytest-sugar>=1.0.0

# requirements/docs.in
sphinx>=5.3.0

# requirements/dev.in
-r tests.in
-r docs.in

A diferencia del Ejemplo 4-9, los requisitos de entrada no enumeran el proyecto en sí. Si lo hicieran, los requisitos de salida incluirían la ruta al proyecto, y cada desarrollador acabaría con una ruta diferente. En lugar de eso, pasa pyproject.toml junto con los requisitos de entrada para bloquear todo el conjunto de dependencias:

$ uv pip compile requirements/tests.in pyproject.toml -o requirements/tests.txt
$ uv pip compile requirements/docs.in -o requirements/docs.txt
$ uv pip compile requirements/dev.in pyproject.toml -o requirements/dev.txt

Recuerda instalar el proyecto después de haber instalado los requisitos de salida.

¿Por qué molestarse en compilar dev.txt? ¿No puede simplemente incluir docs.txt ytests.txt? Si instalas requisitos bloqueados por separado unos sobre otros, es muy posible que acaben entrando en conflicto. Deja que el resolvedor de dependencias vea el cuadro completo. Si pasas todos los requisitos de entrada, puede darte a cambio un árbol de dependencias coherente.

La Tabla 4-3 resume las opciones de la línea de comandos parapip-compile (y uv pip compile) que has visto en este capítulo:

Tabla 4-3. Opciones seleccionadas de la línea de comandos para pip-compile
Opción Descripción

--generate-hashes

Incluye hashes SHA-256 para cada artefacto de empaquetado

--output-file

Especifica el archivo de destino

--quiet

No imprimir los requisitos en el error estándar

--upgrade

Actualiza todas las dependencias a su última versión

--upgrade-package=<package>

Actualizar un paquete concreto a su última versión

--extra=<extra>

Incluir dependencias del extra dado en pyproject.toml

Resumen

En este capítulo, has aprendido a declarar las dependencias del proyecto mediantepyproject.toml y a declarar las dependencias de desarrollo mediante extras o archivos de requisitos. También has aprendido a bloquear dependencias para conseguir implementaciones fiables y comprobaciones reproducibles utilizando pip-tools y uv. En el próximo capítulo, verás cómo el gestor de proyectos Poesía ayuda en la gestión de dependencias utilizando grupos de dependencias y archivos de bloqueo.

1 En un sentido más amplio, las dependencias de un proyecto consisten en todos los paquetes de software que los usuarios necesitan para ejecutar su código, incluidos el intérprete, la biblioteca estándar, los paquetes de terceros y las bibliotecas del sistema. Conda y los gestores de paquetes de distribuciones como APT, DNF y Homebrew admiten esta noción generalizada de dependencias.

2 Henry Schreiner, "¿Deberías utilizar restricciones de versión de límite superior?", 9 de diciembre de 2021.

3 Para simplificar, el código no maneja múltiples autores: cuál de ellos acaba en la cabecera es indefinido.

4 Robert Collins, "PEP 508 - Especificación de dependencias para paquetes de software Python", 11 de noviembre de 2015.

5 Stephen Rosen, "PEP 735 - Grupos de dependencia en pyproject.toml", 20 de noviembre de 2023.

6 Dan Goodin, "Actors Behind PyPI Supply Chain Attack Have Been Active Since Late 2021", 2 de septiembre de 2022.

7 Natalie Weizenbaum, "PubGrub: Solución de versiones de próxima generación", 2 de abril de 2018.

8 Brett Cannon, "Lock Files, Again (But This Time w/ Sdists!)", 22 de febrero de 2024.

9 Desinstalar el paquete no es suficiente: la instalación puede tener efectos secundarios en tu árbol de dependencias. Por ejemplo, puede actualizar o degradar otros paquetes o introducir dependencias adicionales.

Get Herramientas Python hipermodernas now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.