Módulo 3: Reproducir el fallo de CI en tu máquina
4. Dependencias pinneadas contra rangos (`==` vs `>=`)
Descripción
Esta es la lección medular del módulo, porque ataca la reina de las causas de "CI rojo, local verde": una dependencia que en tu máquina tiene una versión y en CI tiene otra. Al terminar vas a entender exactamente por qué las dos máquinas terminan con versiones distintas de la misma librería —aunque las dos "instalen desde el mismo requirements.txt"— y vas a tener la herramienta que lo evita: pinnear, fijar la versión exacta con ==.
Vas a diseccionar el archivo requirements.txt, la diferencia entre == (pin, versión exacta), >= (rango, "esta o más nueva") y no poner nada (lo peor de todo), y el concepto de lockfile —la foto exacta de todas las versiones instaladas que pip freeze te da—. Y no en abstracto: vas a ver, con un venv real que corrí de verdad, cómo un pytz>=2022.1 en una instalación fresca resuelve a la última versión disponible, y cómo el mismo test de Reservo pasa con la versión vieja y falla con la nueva. Es la causa raíz del fallo del módulo, demostrada paso a paso.
Conexión con el módulo. La lección 3 te dio el mapa de las capas del entorno; esta baja a la capa más ruidosa, la de las dependencias, y a la herramienta que la controla. Es la base para la lección 5 (montar un venv limpio con esas versiones exactas) y para la lección 7 (el método completo de reproducción, donde extraer las versiones del log de CI es el primer paso). Todo sobre el requirements.txt de Reservo y su dependencia pytz.
La analogía: "trae leche" contra "trae esta leche exacta"
Le mandas a alguien al supermercado con una nota. Si la nota dice "trae leche", va a traer alguna leche —la que haya, la que esté de oferta, la marca que le guste—. Dos personas con la misma nota, en dos días distintos, traen leches distintas, y ninguna se equivocó: la nota permitía cualquiera. Si la receta que ibas a cocinar necesitaba específicamente leche entera y trajeron deslactosada, el platillo sale distinto, y no es culpa de quien fue al súper: es culpa de la nota ambigua.
Si en cambio la nota dice "trae leche entera marca X, envase de 1 litro, la del código de barras 7501...", cualquiera que vaya, cualquier día, trae exactamente la misma leche. La nota no deja margen. El platillo sale idéntico siempre.
requirements.txt es esa nota, y pip es quien va al supermercado (PyPI, el repositorio de paquetes de Python). Una línea como pytz o pytz>=2022.1 es "trae leche": pip trae alguna versión que cumpla —normalmente la más nueva disponible ese día—. Una línea como pytz==2026.3.post1 es "trae esta leche exacta": pip trae esa versión y ninguna otra. Cuando tu nota es ambigua, tu máquina (que fue al súper hace un año) y CI (que va hoy) traen versiones distintas, y tu suite —la receta— sale distinta en cada una. Pinnear es escribir la nota sin ambigüedad.
Dicho directo:
Un
requirements.txtcon rangos (>=) o sin versión permite que dos instalaciones traigan versiones distintas de la misma librería; pinnear con==fija la versión exacta, de modo que cualquier instalación —tu máquina, CI, la de un compañero— obtenga lo mismo. La reproducibilidad empieza por una nota sin ambigüedad.
Anatomía de requirements.txt
requirements.txt es un archivo de texto plano, una dependencia por línea, que le dice a pip qué instalar. Se usa con pip install -r requirements.txt. Su forma más simple es una lista de nombres:
pytz
pytest
Pero cada línea puede llevar un especificador de versión pegado al nombre, y ahí está todo el juego. Los que vas a ver a diario:
- Sin especificador —
pytz. "Trae la que quieras" (en la práctica, la última disponible). Máxima ambigüedad. - Pin exacto —
pytz==2026.3.post1. "Trae exactamente esta." Máxima reproducibilidad. El operador es==. - Rango con mínimo —
pytz>=2022.1. "Trae esta versión o cualquiera más nueva." Ambiguo hacia arriba: hoy trae una, mañana puede traer otra más nueva. - Rango acotado —
pytz>=2022.1,<2027.0. "Al menos esta, pero por debajo de la 2027." Menos ambiguo, pero todavía deja margen dentro del rango. - Compatible —
pytz~=2026.3. El operador~=significa "esta versión o una compatible más nueva dentro del mismo tramo" (aproximadamente, "puede subir el último número pero no el mayor"). Un punto medio.
Estos operadores son parte de un estándar de Python (la especificación de version specifiers, antes conocida como PEP 440), así que funcionan igual con cualquier herramienta del ecosistema. El archivo admite además comentarios (líneas que empiezan con #) y otras cosas que aquí no necesitamos.
El requirements.txt de Reservo, tal como lo dejó el dev que agregó la funcioncita de hora local, tenía esta línea —y en ella está la semilla del fallo—:
# requirements.txt de Reservo (con el bug de reproducibilidad)
pytz>=2022.1
pytz>=2022.1 dice "trae pytz, versión 2022.1 o más nueva". Parece razonable —"al menos la 2022.1, para tener las correcciones recientes"—, pero es una nota ambigua: no dice cuál más nueva. Y ahí es donde las dos cocinas se separan.
Por qué el mismo archivo instala versiones distintas
Aquí está el corazón del asunto, y es más sutil de lo que parece, porque el requirements.txt es el mismo en las dos máquinas. ¿Cómo pueden instalar versiones distintas del mismo archivo? Por el momento en que cada una instaló y por lo que ya tenían.
En tu máquina. Instalaste las dependencias de Reservo hace un año, cuando la última pytz era la 2022.1. Corriste pip install -r requirements.txt, pip vio pytz>=2022.1, y como la más nueva ese día era la 2022.1, instaló esa. Desde entonces no volviste a instalar nada: pytz 2022.1 sigue ahí, sedimentada. Cada vez que corres los tests, usas esa 2022.1.
En CI. El runner es efímero: cada corrida arranca sin ninguna pytz. Corre pip install -r requirements.txt, pip ve pytz>=2022.1, va a PyPI hoy y busca la más nueva que cumpla "≥ 2022.1". Hoy la más nueva es la 2026.3.post1, así que instala esa. CI no tiene tu sedimento; parte de cero y agarra lo último.
Resultado: el mismo requirements.txt, dos versiones distintas —2022.1 en tu máquina, 2026.3.post1 en CI—, solo porque una instaló hace un año y la otra instala hoy, y la nota (>=) permitía ambas. Si la línea hubiera dicho pytz==2022.1, las dos habrían tenido exactamente la 2022.1 y no habría brecha. Si hubiera dicho pytz==2026.3.post1, las dos habrían tenido la 2026.3.post1. El pin elimina el margen; el rango lo deja abierto, y "el momento de instalar" se cuela por ese margen.
Este es exactamente el mecanismo que rompió el test de Reservo. La pytz 2022.1 de tu máquina todavía cree que Ciudad de México tiene horario de verano; la pytz 2026.3.post1 de CI ya sabe que no. El >= dejó que cada máquina agarrara una data de zonas horarias distinta, y con eso el test de la hora local pasó en una y falló en la otra.
Ejemplo trabajado: verlo ocurrir en un venv real
No me creas de palabra; míralo. Monté un venv limpio con el requirements.txt de Reservo tal cual (pytz>=2022.1) y observé qué versión resuelve pip hoy, con una instalación fresca —justo lo que hace CI—.
Qué esperar. Creas el entorno, instalas desde el requirements.txt con el rango, y preguntas qué versión quedó:
$ python3.14 -m venv fresh-venv
$ fresh-venv/bin/python -m pip install -r requirements.txt
$ fresh-venv/bin/python -c "import pytz; print('pytz resolved to:', pytz.__version__)"
pytz resolved to: 2026.3.post1
Ahí está el mecanismo, desnudo: el requirements.txt decía pytz>=2022.1, y una instalación fresca resolvió a 2026.3.post1 —la más nueva disponible—, no a la 2022.1 que tú tienes sedimentada. Ese salto de versión, que el rango permitió en silencio, es la brecha.
Ahora corre el test de Reservo en dos entornos que difieren solo en la versión de pytz. Primero, el que replica tu máquina (con la vieja 2022.1):
$ old-venv/bin/python -c "import pytz; print(pytz.__version__)"
2022.1
$ old-venv/bin/python -m pytest test_localtime.py -q
1 passed in 0.02s
Y el que replica CI (con la fresca 2026.3.post1):
$ fresh-venv/bin/python -c "import pytz; print(pytz.__version__)"
2026.3.post1
$ fresh-venv/bin/python -m pytest test_localtime.py -q
F [100%]
=================================== FAILURES ===================================
____________________ test_summer_booking_starts_at_16_local ____________________
> assert local_start_hour(a_booking(), "America/Mexico_City") == 16
E AssertionError: assert 15 == 16
test_localtime.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_localtime.py::test_summer_booking_starts_at_16_local - assert 15 == 16
1 failed in 0.04s
Verde con la vieja, rojo con la nueva. La única variable que cambió entre las dos corridas es el número de versión de una librería, y ese número vino determinado por cuándo se instaló, porque la nota (>=) lo permitía. Esto no es un caso de laboratorio artificial: es el mecanismo por el que miles de suites se rompen "sin que nadie tocara nada". Nadie tocó el código; alguien —el tiempo— movió la versión que el rango dejó suelta.
El lockfile: la foto de lo que de verdad se instaló
Si el problema es que >= deja la versión al azar del momento, la solución de fondo es congelar las versiones exactas que funcionaron y usarlas en todas partes. Esa foto congelada se llama lockfile, y la herramienta más simple para obtenerla es pip freeze: lista toda dependencia instalada en el entorno actual, con su versión exacta, en formato nombre==version —listo para pegar en un requirements.txt—.
Qué esperar. En el venv que ya tiene todo instalado:
$ python -m pip freeze
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2026.3.post1
Mira lo que te da, y lo que eso significa. No solo aparece pytz con su versión exacta; aparecen todas las librerías del entorno, incluidas las que ni pediste directamente —iniconfig, packaging, pluggy son dependencias de pytest, que se instalaron solas—. Un lockfile captura el árbol completo, no solo tus dependencias directas. Esa es su virtud: si guardas esta foto y la reinstalas en otra máquina, obtienes exactamente el mismo conjunto de versiones, hasta las indirectas. La cocina queda clonada.
El flujo típico para volver reproducible un proyecto es: instalas y trabajas hasta que la suite está verde, corres pip freeze para capturar las versiones que funcionan, y guardas esa salida como el requirements.txt (o como un requirements.lock aparte). A partir de ahí, pip install -r en cualquier cocina —tu máquina, CI, la de un compañero— reproduce el mismo entorno. El >= ambiguo se convirtió en un montón de == exactos, y la brecha de dependencias desaparece.
Una nota honesta sobre alcance: pip freeze es la forma más simple y directa de un lockfile, y suficiente para lo que este módulo necesita. Herramientas más nuevas (como pip-tools, Poetry o uv) generan lockfiles más completos, que además fijan hashes y separan las dependencias directas de las indirectas. El principio es el mismo en todas: congelar versiones exactas para que la instalación sea determinista. Aquí nos quedamos con pip freeze y requirements.txt, que es el mínimo que cierra la brecha del módulo.
Pin, rango y sin versión: cuándo cada uno
Si pinnear con == da reproducibilidad, ¿por qué existe siquiera el >=? Porque hay una tensión real entre dos cosas buenas: reproducibilidad (que la instalación sea siempre igual) y frescura (recibir correcciones y mejoras de las librerías sin editar el archivo a mano). Un vistazo honesto a los tres modos:
- Sin versión (
pytz) — casi nunca es buena idea para un proyecto que corre en CI. Máxima frescura, cero reproducibilidad: cada instalación puede traer algo distinto, y te expone a que una versión nueva rompa tu suite sin aviso. Es la nota más ambigua posible. - Rango (
pytz>=2022.1) — cómodo pero traicionero, justo por lo que viste: deja que el momento de instalar decida la versión, así que tu local y CI divergen. Tiene su lugar cuando publicas una librería que otros consumen (quieres ser flexible con las versiones de tus usuarios), pero para la aplicación que corres en CI es una fuente de fallos fantasma. - Pin exacto (
pytz==2026.3.post1) — la elección por defecto para una aplicación con CI. Máxima reproducibilidad: todas las cocinas obtienen lo mismo. El costo es que actualizar requiere editar el archivo (o regenerar el lockfile) a propósito —lo cual, bien mirado, es una virtud: subes de versión cuando tú decides y corres los tests, no cuando el azar del momento lo decide por ti—.
La regla práctica para una app: pinnea todo (usa un lockfile de pip freeze), y actualiza las versiones de forma deliberada y controlada —cambias el pin, corres la suite, si sigue verde subes el cambio—. Así la frescura la manejas tú, con la red de tus tests debajo, en vez de dejarla a merced de cuándo se ejecutó la instalación. La matriz de versiones del módulo 4 es la otra cara de esto: en vez de evitar que las versiones cambien, prueba tu suite contra varias a propósito, para saber de antemano con cuáles funcionas.
Profundización: "sin que nadie tocara nada", explicado
Hay una frase que se repite en cada equipo cuando aparece este fallo: "pero si nadie tocó nada, ¿cómo se rompió?". Es literalmente cierta —el código no cambió, el test no cambió, el requirements.txt no cambió— y aun así el build pasó de verde a rojo. La profundización que cierra el módulo es entender que esa frase esconde un error de suposición: cree que "nada cambió" porque solo mira el repositorio. Pero el entorno de una corrida de CI no lo determina solo el repositorio; lo determina el repositorio más el estado del mundo en el momento de instalar. Y el mundo sí cambió: entre una corrida y otra, se publicó una versión nueva de pytz.
Míralo como una función. El resultado de tu suite es resultado = f(código, entorno). Tú controlas código (está en git, versionado, con historia). Pero entorno, cuando tienes un >=, no está del todo bajo tu control: una parte de él —qué versión resuelve el rango— la decide cuándo corre la instalación, que es una variable externa que no vive en tu repo. Así que aunque código no cambie, entorno puede cambiar solo, y con él el resultado. "Nadie tocó nada" es verdad sobre código y falso sobre entorno. El fallo no salió de la nada; salió de la única entrada que dejaste sin fijar.
Esto reencuadra qué hace realmente pinnear. Pinnear no es "ser cuidadoso con las versiones"; es mover la variable entorno de fuera de tu control a dentro de git. Con pytz==2026.3.post1 en un archivo versionado, la versión ya no la decide el momento de instalar: la decide una línea que tú escribiste, que tiene historia, que se revisa en un pull request, que cambia solo cuando alguien hace un commit deliberado. El entorno se vuelve tan versionado y auditable como el código. Y entonces "nadie tocó nada" recupera su sentido honesto: si de verdad nadie tocó ni el código ni los pins, el resultado no puede cambiar —porque ya no queda ninguna entrada suelta por la que el mundo se cuele—. La reproducibilidad, en el fondo, es eso: no dejar ninguna entrada de f fuera del control de versiones.
Errores comunes
Creer que "el mismo requirements.txt" garantiza el mismo entorno. Qué pasa: como CI y tu máquina instalan del mismo archivo, asumes que quedan idénticas, y descartas las dependencias como causa del fallo. Por qué pasa: es intuitivo que "mismo archivo → mismo resultado". Cómo detectarlo: si el requirements.txt tiene >= o líneas sin versión, el "mismo archivo" no garantiza nada. Cómo corregirlo: recuerda que un rango resuelve según cuándo instalas; compara las versiones reales con pip freeze en cada máquina, no las líneas del archivo. Dos pip freeze distintos con el mismo requirements.txt es la prueba del delito.
Pinnear solo tus dependencias directas y olvidar las indirectas. Qué pasa: fijas pytz==... y pytest==... en tu requirements.txt, pero no las librerías que esas arrastran (pluggy, packaging, etc.), y una actualización de una indirecta rompe algo. Por qué pasa: las indirectas son invisibles en tu archivo; ni las escribiste. Cómo detectarlo: si tu requirements.txt tiene menos líneas que la salida de pip freeze, tienes indirectas sin pinnear. Cómo corregirlo: usa pip freeze para capturar el árbol completo como lockfile, no solo tus dependencias directas. Un entorno determinista fija todo lo que se instala, no solo lo que pediste.
Actualizar el pin "a ojo" sin correr la suite. Qué pasa: subes pytz==2022.1 a pytz==2026.3.post1 para "estar al día", commiteas, y resulta que la nueva versión rompe un test —te enteras en CI—. Por qué pasa: se siente seguro subir de versión, y se olvida que una versión nueva puede cambiar comportamiento. Cómo detectarlo: si cambiaste un pin y no corriste pytest local antes de subir, saltaste la red de seguridad. Cómo corregirlo: cada cambio de pin es un cambio de comportamiento potencial; trátalo como tal. Cambia el pin, corre la suite completa en un venv limpio, y solo si sigue verde, súbelo. Ese es el sentido de pinnear: que las actualizaciones pasen por tus tests, no por sorpresa.
Ejercicios
Ejercicio 1 — Lee la nota. Para cada línea de requirements.txt, di qué versión instalaría hoy una instalación fresca y si es reproducible (¿dos instalaciones en fechas distintas darían lo mismo?). Supón que las versiones publicadas de pytz son 2022.1, 2024.2 y 2026.3.post1. (a) pytz. (b) pytz==2024.2. (c) pytz>=2022.1. (d) pytz>=2022.1,<2025.0.
Ver solución
- (a)
pytz: instala la más nueva disponible → 2026.3.post1 hoy. No reproducible: mañana, si sale una 2027.x, instalaría esa. - (b)
pytz==2024.2: instala exactamente 2024.2. Reproducible: cualquier instalación, cualquier día, da 2024.2. - (c)
pytz>=2022.1: instala la más nueva que sea ≥ 2022.1 → 2026.3.post1 hoy. No reproducible: el "más nueva" cambia con el tiempo. - (d)
pytz>=2022.1,<2025.0: instala la más nueva que sea ≥ 2022.1 y < 2025.0 → 2024.2 (la 2026.3.post1 queda fuera por el<2025.0). Casi reproducible: hoy da 2024.2, y seguiría dándola mientras no se publique una versión entre 2024.2 y 2025.0; el techo<2025.0reduce el margen pero no lo elimina del todo.
Ejercicio 2 — Del rango al lock. Tienes un venv donde la suite de Reservo está verde, con este pip freeze:
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2022.1
Tu requirements.txt actual dice solo pytz>=2022.1 y pytest. Escribe el requirements.txt que harías reproducible este entorno, y explica por qué incluirías más líneas de las que tenías.
Ver solución
El requirements.txt reproducible es, literalmente, la salida de pip freeze (el lockfile):
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2022.1
Por qué más líneas que las dos originales: el requirements.txt viejo solo listaba tus dependencias directas (pytz y pytest). Pero pytest arrastra dependencias indirectas —iniconfig, packaging, pluggy— que se instalaron solas y que también pueden cambiar de comportamiento entre versiones. Un lockfile las fija todas, así que otra máquina obtiene el árbol completo idéntico, no solo pytz y pytest. Además, se pinnearon con == (versión exacta) en vez de >=, para que el "cuándo se instala" ya no pueda mover ninguna versión. Con este archivo, cualquier pip install -r reproduce el entorno donde la suite estaba verde.
Ejercicio 3 — Explica la divergencia. Un compañero insiste: "No entiendo, los dos tenemos pytz>=2022.1 en el mismo requirements.txt, ¿cómo es posible que a mí me quede la 2022.1 y en CI la 2026.3.post1?" Explícale el mecanismo en tres o cuatro frases, sin jerga.
Ver solución
Una explicación posible: "pytz>=2022.1 no dice una versión; dice 'la 2022.1 o cualquiera más nueva'. Cuando tú instalaste, hace un año, la más nueva que existía era la 2022.1, así que te quedó esa —y sigue ahí, nunca la actualizaste—. CI, en cambio, instala desde cero en cada corrida: hoy va a PyPI, busca 'la más nueva que sea ≥ 2022.1', y hoy esa es la 2026.3.post1, así que instala esa. El mismo archivo, pero cada uno instaló en un momento distinto, y el >= dejó que 'el momento' eligiera la versión. Si en vez de >= pusiéramos == con una versión exacta, los dos tendríamos la misma sin importar cuándo instalamos."
Resumen y siguiente paso
En esta lección atacaste la reina de las causas de "CI rojo, local verde": una dependencia con versiones distintas en cada máquina. Disecaste requirements.txt y sus especificadores —sin versión (máxima ambigüedad), == (pin, reproducible), >= (rango, ambiguo hacia arriba), rangos acotados y ~=— y entendiste el mecanismo exacto de la divergencia: el mismo requirements.txt con un >= instala versiones distintas según cuándo se ejecute, porque tu máquina instaló hace un año (y sedimentó la 2022.1) y CI instala hoy (y agarra la 2026.3.post1). Lo viste ocurrir en un venv real: pytz>=2022.1 resolvió a 2026.3.post1 en una instalación fresca, y el mismo test de Reservo pasó con la vieja y falló con la nueva.
Conociste el lockfile —la foto exacta de todas las versiones instaladas, que pip freeze te da lista para pegar en requirements.txt, incluidas las indirectas— y la regla para una aplicación con CI: pinnea todo y actualiza de forma deliberada, con la red de tus tests debajo, en vez de dejar la versión a merced del momento de instalar.
Antes de avanzar deberías poder: explicar por qué >= hace divergir a CI y local aunque el archivo sea el mismo; escribir un pin exacto y un rango; generar un lockfile con pip freeze y decir por qué incluye más líneas que tus dependencias directas; y elegir entre pin y rango según sea una app o una librería.
Lo que sigue es poner el pin en práctica en la herramienta que reproduce el entorno de CI dentro de tu máquina. La lección 5 arma un venv limpio —una cocina vacía, sin sedimento— e instala en él las versiones exactas que usó CI con pip install -r requirements.txt, para reproducir el rojo a voluntad. Es donde el pin de esta lección se convierte en una reproducción real.
Recursos
- Formato del archivo de requerimientos — documentación de pip — la referencia oficial de
requirements.txt: qué significa cada línea, los especificadores de versión y los comentarios. La nota del supermercado, en detalle. - Instalaciones repetibles — documentación de pip — la guía de pip sobre cómo lograr instalaciones deterministas con versiones fijas y lockfiles. Es la tesis de esta lección, dicha por la herramienta misma.
pip freeze— documentación de pip — el comando que captura la foto exacta del entorno, incluidas las dependencias indirectas. La forma más simple de un lockfile.- Especificadores de versión (antes PEP 440) — Python Packaging — el estándar detrás de
==,>=,~=y compañía. Útil para entender exactamente qué permite cada operador.