Dependencias transitivas y lockfiles en Python

Cuando añades requests a tu proyecto, no estás añadiendo una dependencia — estás añadiendo un árbol. requests necesita urllib3, certifi, charset-normalizer e idna. Si otro paquete que usas también necesita urllib3 pero con restricciones de versión distintas, alguien tiene que resolver ese conflicto. Ese “alguien” es el resolvedor de dependencias, y entender cómo funciona cambia completamente cómo diseñas tus proyectos.

Directas vs. transitivas: la distinción que importa

Una dependencia directa es la que tú declaras explícitamente: flask, sqlalchemy, pydantic. Una dependencia transitiva es la que tus dependencias necesitan para funcionar. Tú no la pediste, pero sin ella el código falla.

El problema real aparece cuando el árbol crece y las restricciones se contradicen. Imagina este escenario:

  • Tu proyecto necesita library-a>=2.0 y library-b>=1.5
  • library-a 2.0 requiere httpx>=0.24,<0.26
  • library-b 1.5 requiere httpx>=0.25

Existe solución: httpx 0.25.x satisface ambas restricciones. Pero si library-b hubiera pedido httpx>=0.26, no existiría ninguna versión válida y tendrías un conflicto de dependencias real.

Esto no es un problema trivial. Encontrar si existe una asignación de versiones que satisfaga todas las restricciones simultáneamente es un problema NP-completo — formalmente equivalente a SAT (Boolean Satisfiability Problem). Los resolvedores modernos como el de pip (basado en PubGrub) o el de poetry usan algoritmos de backtracking con heurísticas para no explotar combinatoriamente, pero en proyectos grandes con muchas restricciones, la resolución puede tardar segundos o incluso fallar de forma confusa.

Por qué pip install sin lockfile es ruleta rusa

Sin un lockfile, cada vez que alguien hace pip install -r requirements.txt con versiones sin fijar, el resolvedor elige las versiones más recientes compatibles en ese momento. Si library-a lanzó 2.1 ayer y 2.1 introdujo un bug, tu colega que clona hoy el repo tiene un entorno diferente al tuyo aunque usen el mismo requirements.txt.

Un lockfile congela el resultado completo de la resolución: todas las dependencias directas y transitivas, con sus versiones exactas y (en herramientas modernas) sus hashes de integridad. No hay ambigüedad.

Pero hay una distinción crítica que mucha gente ignora: las librerías no deben tener lockfile propio.

Si publicas mi-libreria en PyPI y commitas un lockfile, ese lockfile solo afecta a tu entorno de desarrollo — PyPI lo ignora completamente. Cuando alguien instala mi-libreria como dependencia de su aplicación, el resolvedor usa las restricciones de tu pyproject.toml, no tu lockfile. Si tus restricciones son demasiado estrictas (urllib3==2.0.7 en vez de urllib3>=2.0), estás causando conflictos innecesarios en los proyectos de tus usuarios. Las librerías deben declarar rangos amplios y razonables.

Las aplicaciones, en cambio, son el punto final de la cadena — nada las instala como dependencia. Aquí el lockfile es obligatorio para reproducibilidad en producción, CI y entre el equipo.

# pyproject.toml de una LIBRERÍA — restricciones amplias, sin lockfile commiteado
# [build-system] requiere hatchling o flit, no poetry-lock

# demo_resolver.py
# Simula manualmente lo que hace un resolvedor básico
# para entender el problema de las dependencias transitivas.

from dataclasses import dataclass
from typing import Optional
from packaging.version import Version
from packaging.specifiers import SpecifierSet


@dataclass
class Package:
    name: str
    version: str
    requires: dict[str, str]  # {package_name: version_specifier}


# Universo de paquetes disponibles (simula el índice de PyPI)
PACKAGE_INDEX: dict[str, list[Package]] = {
    "library-a": [
        Package("library-a", "2.1.0", {"httpx": ">=0.24,<0.26"}),
        Package("library-a", "2.0.0", {"httpx": ">=0.24,<0.26"}),
    ],
    "library-b": [
        Package("library-b", "1.6.0", {"httpx": ">=0.25"}),
        Package("library-b", "1.5.0", {"httpx": ">=0.25"}),
    ],
    "httpx": [
        Package("httpx", "0.27.0", {}),
        Package("httpx", "0.25.2", {}),
        Package("httpx", "0.24.1", {}),
    ],
}


def find_compatible_version(
    package_name: str,
    constraints: list[str],
) -> Optional[Package]:
    """
    Intenta encontrar la versión más reciente que satisfaga
    todas las restricciones acumuladas sobre un paquete.
    En resolvedores reales, aquí vive el backtracking.
    """
    candidates = PACKAGE_INDEX.get(package_name, [])
    
    # Combina todas las restricciones en un solo SpecifierSet
    combined = SpecifierSet(",".join(constraints))
    
    # Los candidatos ya están ordenados de más reciente a más antiguo
    for candidate in candidates:
        if Version(candidate.version) in combined:
            return candidate
    
    return None  # Conflicto: ninguna versión satisface todas las restricciones


def resolve(direct_deps: dict[str, str]) -> dict[str, str] | str:
    """
    Resolución simplificada (sin backtracking completo).
    Devuelve el lockfile resultante o un mensaje de conflicto.
    """
    # accumulated_constraints guarda todas las restricciones vistas
    # sobre cada paquete, vengan de donde vengan
    accumulated_constraints: dict[str, list[str]] = {}
    resolution: dict[str, str] = {}
    
    # Cola de trabajo: (nombre_paquete, restricción_requerida)
    work_queue: list[tuple[str, str]] = list(direct_deps.items())
    
    while work_queue:
        pkg_name, specifier = work_queue.pop(0)
        
        if pkg_name not in accumulated_constraints:
            accumulated_constraints[pkg_name] = []
        accumulated_constraints[pkg_name].append(specifier)
        
        result = find_compatible_version(
            pkg_name,
            accumulated_constraints[pkg_name],
        )
        
        if result is None:
            constraints_str = " AND ".join(accumulated_constraints[pkg_name])
            return (
                f"CONFLICT: no version of '{pkg_name}' "
                f"satisfies: {constraints_str}"
            )
        
        # Si ya resolvimos este paquete a la misma versión, no re-procesamos
        if resolution.get(pkg_name) == result.version:
            continue
            
        resolution[pkg_name] = result.version
        
        # Las dependencias transitivas entran a la cola igual que las directas
        # — aquí es donde el árbol crece
        for transitive_name, transitive_spec in result.requires.items():
            work_queue.append((transitive_name, transitive_spec))
    
    return resolution


if __name__ == "__main__":
    print("=== Escenario compatible ===")
    deps = {
        "library-a": ">=2.0",
        "library-b": ">=1.5",
    }
    result = resolve(deps)
    print(result)
    # {'library-a': '2.1.0', 'library-b': '1.6.0', 'httpx': '0.25.2'}
    # httpx 0.25.2 satisface >=0.24,<0.26 (de A) Y >=0.25 (de B)

    print("\n=== Escenario con conflicto ===")
    conflicting_deps = {
        "library-a": ">=2.0",   # necesita httpx >=0.24,<0.26
        "httpx": ">=0.26",       # nosotros pedimos explícitamente 0.26+
    }
    result = resolve(conflicting_deps)
    print(result)
    # CONFLICT: no version of 'httpx' satisfies: >=0.24,<0.26 AND >=0.26

Lo que el código revela sobre el problema real

La función resolve trata las dependencias directas y las transitivas de forma idéntica — ambas entran a work_queue y acumulan restricciones en accumulated_constraints. Esto es exactamente lo que hace pip: no hay ciudadanos de primera y segunda clase; todas las restricciones, vengan de tu pyproject.toml o del requires de una dependencia de cuarto nivel, compiten en el mismo espacio.

Fíjate en la línea donde result.requires se añade a la cola. Cuando library-a resuelve a 2.1.0, sus dependencias transitivas (httpx>=0.24,<0.26) se encolan junto con las directas ya existentes. Si library-b ya había encolado httpx>=0.25, accumulated_constraints["httpx"] acaba con dos entradas: [">=0.24,<0.26", ">=0.25"]. El SpecifierSet combinado es >=0.24,<0.26,>=0.25, y httpx 0.25.2 cae dentro. Conflicto resuelto.

El escenario conflictivo ilustra algo importante: a veces eres quien crea el conflicto. Si en tu pyproject.toml pides httpx>=0.26 mientras una dependencia tuya restringe httpx<0.26, el resolvedor no tiene a quién culpar más que a tus propias restricciones directas siendo demasiado estrictas.

El resolvedor real de pip (PubGrub) es mucho más sofisticado — hace backtracking, propaga restricciones hacia atrás y genera mensajes de error que explican por qué el conflicto es irresoluble, no solo que lo es. Pero el modelo mental es el mismo: un grafo de restricciones que hay que satisfacer simultáneamente.

La diferencia entre librería y aplicación cristaliza en un punto concreto: cuando ejecutas poetry lock o pip-compile, estás materializando una solución a ese problema SAT en un archivo de texto. Ese archivo es tu contrato con el tiempo — garantiza que el entorno de CI del martes reproduce el entorno de tu máquina del lunes, independientemente de qué versiones nuevas haya publicado nadie en PyPI mientras dormías.

Errores que debes conocer

Error: Commitear poetry.lock en una librería publicada en PyPI, pensando que eso protege a tus usuarios.

# ❌ Wrong — en una librería, commiteas el lock y además
# fijas versiones exactas en las dependencias
[tool.poetry.dependencies]
python = "^3.11"
httpx = "0.25.2"  # versión exacta en una librería
# ✅ Right — rangos amplios, el lockfile en .gitignore para librerías
[tool.poetry.dependencies]
python = "^3.11"
httpx = ">=0.24,<1.0"  # compatible con más entornos de usuario

El lockfile de tu librería no viaja con ella a PyPI — pero las restricciones exactas sí, y pueden bloquear la resolución de dependencias de tus usuarios.


Error: Usar requirements.txt sin hashes en producción, asumiendo que las versiones fijadas son suficiente garantía.

# ❌ Wrong — versión fijada pero sin hash: un atacante que comprometa PyPI
# podría servir un paquete diferente con la misma versión
httpx==0.25.2
# ✅ Right — genera esto con: pip-compile --generate-hashes
httpx==0.25.2 \
    --hash=sha256:a05d3d052d9b2dfce0e3896636467f8a... \
    --hash=sha256:b96c03becd2afb82b42ff965fc38c4...

Con hashes, pip verifica la integridad del archivo descargado contra el lockfile — si el contenido no coincide, la instalación falla en lugar de ejecutar código potencialmente malicioso.


Error: Ignorar los warnings de pip sobre resolución lenta o conflictos parciales asumiendo que “si instala, funciona”.

# ❌ Wrong — ignorar esto:
# WARNING: pip is configured with locations that require TLS/SSL,
# however the ssl module in Python is not available.
# Defaulting to user installation because normal site-packages is not
# writeable. ... Successfully installed library-a-2.1 httpx-0.24.1
# (nota: pip eligió httpx 0.24.1 en silencio, no 0.25.x)

pip install library-a library-b
# ✅ Right — usa pip-compile o poetry para ver exactamente qué resolvió
# y por qué, antes de que llegue a producción
pip-compile pyproject.toml --output-file requirements.lock
cat requirements.lock  # audita el resultado antes de commitearlo

pip install directo puede resolver silenciosamente a versiones antiguas de dependencias transitivas sin advertirte; las herramientas de lockfile hacen el proceso explícito y reproducible.

74

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio