Módulo 8: Proyecto — un pipeline de CI para Reservo
3. Dependencias pinneadas y reproducibilidad
Descripción
El piso que montaste en la lección anterior corre la suite en cada push. Pero tiene una grieta silenciosa: instala pytest y punto, dejando que el runner resuelva por su cuenta las demás versiones —las dependencias transitivas de pytest, y las herramientas de CI que las próximas lecciones traerán—. Mientras esas versiones coincidan con las tuyas, todo va bien. El día que no coincidan —el runner instala una versión más nueva de una librería que la que tú tienes—, aparece el fantasma que da nombre a media guía: el CI en rojo y tu local en verde, sin que hayas tocado el código. Esta lección cierra esa grieta con la capa de la reproducibilidad: fijar las versiones exactas de todo lo que el pipeline instala, para que el runner instale idéntico a ti.
Es la capa del módulo 3, vista ahora como parte del conjunto. La idea es simple de enunciar y fácil de subestimar: un pipeline solo es confiable si es determinista, y una instalación es determinista solo si cada versión está clavada. Vas a distinguir el requirements.txt de producción del requirements-dev.txt que trae las herramientas de CI, entender por qué el pin exacto (==) vence a los rangos (>=), y usar pip freeze para tomar la foto de versiones que convierte "instala lo que haya" en "instala exactamente esto". La salida de pip freeze que verás es real, tomada del entorno donde corre la guía.
Al terminar vas a saber hacer que tu pipeline instale lo mismo en el runner que en tu terminal, y a entender por qué esa igualdad —no la suerte de que las versiones coincidan— es lo que hace que la paridad local sea una promesa y no una esperanza.
Conexión con el módulo: esta es la segunda capa que apilamos sobre el piso de la lección 2. El workflow base ya instala desde un archivo; aquí ese archivo pasa a fijar todo con versiones exactas, y se divide en dos —producción y desarrollo/CI—. La reproducibilidad que ganas aquí es la que hace legítima cada corrida de las lecciones siguientes: cuando en la lección 6 midamos 88% de cobertura, ese número solo es reproducible si las versiones están clavadas; cuando en la lección 4 la matriz corra en tres versiones, cada celda instala una foto determinista. Sin esta capa, los números del pipeline flotarían. Con ella, son un contrato.
La receta que decía "una pizca de sal"
Piensa en una receta que se pasa de una cocina a otra. La primera versión dice "una pizca de sal, un chorrito de aceite, hornea hasta que se vea listo". En la cocina de quien la inventó, sale perfecta —porque "una pizca" es su pizca, "hasta que se vea listo" es su ojo—. Pero cuando la receta viaja a otra cocina, con otra mano y otro horno, "una pizca" se vuelve el doble, "un chorrito" la mitad, y "hasta que se vea listo" son diez minutos de más. El plato sale distinto, y nadie entiende por qué, si "es la misma receta".
El problema no es la receta; es que no es reproducible. Las cantidades vagas dejan que cada cocina las interprete, así que el mismo texto produce platos distintos. La solución de un chef que quiere que su receta salga igual en cualquier lado es precisar: "5 gramos de sal, 15 mililitros de aceite, 18 minutos a 180°C". Ahora la receta ya no depende de la mano ni del ojo de quien la ejecuta; produce el mismo plato en cualquier cocina, porque no deja nada a la interpretación.
Tus dependencias son las cantidades de la receta. pytest a secas es "una pizca de pytest": el runner instala alguna versión, tú tienes otra, y el mismo requirements.txt produce entornos distintos. pytest==9.1.1 es "5 gramos de pytest": clava la versión exacta, así que el runner y tu máquina instalan idéntico, y el pipeline se vuelve reproducible. Pinnear las dependencias es escribir la receta en gramos en vez de en pizcas —para que "en mi cocina funciona" deje de ser una diferencia entre cocinas—.
Un pipeline reproducible instala versiones exactas, no rangos.
pytest==9.1.1produce el mismo entorno en el runner y en tu máquina;pytesta secas deja que cada uno instale lo que haya, y ahí nace el "en mi máquina funciona".
Dos archivos: producción y desarrollo/CI
Reservo no necesita ninguna librería externa para funcionar —es lógica de Python puro—. Lo que necesita son herramientas para probarse en CI: pytest, y las que las próximas lecciones activan (cobertura, paralelismo, reintentos). Esa distinción se refleja en dos archivos, y separarlos es una buena práctica que vale la pena entender.
requirements.txt — lo que el proyecto necesita para correr en producción. Para Reservo, que es stdlib pura, está casi vacío: solo pytest, porque incluso en producción quisieras poder correr los tests. En un proyecto con dependencias reales (una app web, digamos), aquí irían el framework, el cliente de base de datos, etc.
# requirements.txt
pytest==9.1.1
requirements-dev.txt — lo que necesitas para desarrollar y probar en CI, que es un superconjunto: incluye lo de producción más las herramientas del pipeline. Es el archivo que el CI instala, porque el CI necesita todo el instrumental de testing:
# requirements-dev.txt
pytest==9.1.1
pytest-cov==7.1.0
pytest-xdist==3.8.0
pytest-rerunfailures==16.4
Cada línea, con su ==, es una etapa futura del pipeline esperando: pytest-cov es la puerta de cobertura (lección 6), pytest-xdist es el paralelismo (lección 5), pytest-rerunfailures es la política de flaky (lección 7). Al pinnearlas todas ahora, garantizas que el runner y tu máquina traen exactamente las mismas herramientas —y por tanto se comportan igual—. El workflow, a partir de esta lección, instala desde requirements-dev.txt:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
pip freeze: la foto exacta de versiones
Pinnear las cuatro herramientas de arriba fija lo que tú pediste, pero cada una arrastra dependencias transitivas —pytest necesita pluggy, iniconfig, packaging; xdist necesita execnet— y esas también pueden variar entre entornos. La herramienta que captura todo el árbol, tuyo y transitivo, es pip freeze: lista cada paquete instalado con su versión exacta, en el formato que un requirements entiende. Es la foto completa de tu entorno.
pip freeze
Qué esperar (salida real del entorno donde corre la guía, filtrada a los paquetes relevantes):
coverage==7.15.2
execnet==2.1.2
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
Pygments==2.20.0
pytest==9.1.1
pytest-cov==7.1.0
pytest-rerunfailures==16.4
pytest-xdist==3.8.0
Mira todo lo que aparece que no escribiste a mano. Pediste cuatro paquetes (pytest, pytest-cov, pytest-xdist, pytest-rerunfailures), pero el entorno tiene diez, porque cada uno trajo su séquito: coverage (el motor detrás de pytest-cov), execnet (el que pytest-xdist usa para hablar entre procesos), pluggy, iniconfig, packaging, Pygments. Esas dependencias transitivas son las que más silenciosamente se desincronizan: nunca las nombraste, así que ni te enteras cuando el runner instala una versión distinta —hasta que un cambio de comportamiento entre versiones te da un rojo inexplicable—.
Aquí está el punto fino de la reproducibilidad. Un requirements-dev.txt con solo las cuatro líneas de alto nivel es bastante determinista, pero no del todo: las versiones transitivas quedan a merced de lo que pip resuelva el día de la instalación. Para un pipeline verdaderamente clavado, la práctica más estricta es congelar la foto entera —guardar la salida de pip freeze como el archivo que el CI instala—, de modo que hasta execnet y pluggy tengan su versión fija. Para Reservo, con dependencias tan estables, las cuatro líneas de alto nivel bastan; en un proyecto grande, con decenas de dependencias que se mueven rápido, congelar la foto completa (o usar un archivo de lock como los que generan pip-tools, Poetry o uv) es lo que hace que "el CI instaló lo mismo que yo" sea literalmente cierto, no aproximadamente.
Ejemplo trabajado: reproducir en local lo que el CI instaló
La paridad local no es solo correr la misma suite; es correrla sobre las mismas versiones. Con la foto de pip freeze, puedes recrear en tu terminal el entorno exacto del runner. El patrón, que es el corazón del módulo 3:
# 1. entorno virtual limpio, para no arrastrar versiones viejas
python3.14 -m venv .venv
source .venv/bin/activate
# 2. instalar exactamente lo que el CI instala, desde la lista pinneada
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# 3. confirmar que las versiones coinciden con la foto del runner
pip freeze
Si tu pip freeze produce la misma lista que el runner reportaría, tienes paridad de entorno: no solo corres la misma suite, la corres sobre el mismo instrumental, versión por versión. Y entonces —solo entonces— tiene sentido comparar resultados: si tu suite pasa aquí, pasará allá, porque aquí y allá son el mismo entorno.
El entorno virtual limpio (python -m venv .venv) es la mitad no negociable del truco. Sin él, instalarías sobre lo que ya tengas —versiones viejas de proyectos anteriores, paquetes globales—, y tu "reproducción" arrastraría contaminación que el runner (que arranca de cero cada vez) no tiene. Un venv nuevo es la única forma de igualar el punto de partida del runner: una máquina limpia donde solo existe lo que requirements-dev.txt instala.
Por qué esta capa hace legítimos los números del pipeline
Vale la pena detenerse en algo que se vuelve crítico en las próximas lecciones. Todo número que el pipeline reporte —cuántos tests pasan, cuánta cobertura hay, cuánto tarda— es un número sobre un entorno. Si el entorno cambia entre corridas, el número cambia sin que el código cambie, y deja de significar nada.
Piénsalo con la cobertura, que es la lección 6. Vamos a medir que Reservo tiene 88% de cobertura y a poner una puerta que rompe el build si baja. Pero "88%" solo es un contrato reproducible si la próxima corrida mide sobre el mismo coverage, el mismo pytest, la misma suite. Si coverage saltara de la versión 7.15 a una 8.0 que cuenta las ramas distinto, el 88% podría volverse 86% sin que nadie tocara el código, y la puerta rompería el build por un fantasma. Pinnear coverage==7.15.2 (vía pytest-cov==7.1.0, que lo arrastra) es lo que hace que el 88% de hoy sea comparable con el 88% de mañana.
Lo mismo con la matriz (lección 4): cada celda instala su propia foto, y solo si esa foto es determinista puedes leer "la celda de 3.11 falló" como "el bug es de 3.11" y no como "quizá la celda de 3.11 instaló una versión rara de una dependencia". La reproducibilidad no es una etapa aislada; es el suelo firme sobre el que las demás etapas dan mediciones en las que puedes confiar. Un pipeline sin dependencias pinneadas mide sobre arena.
Errores comunes
Pinnear solo lo de alto nivel y creer que ya es determinista. Qué pasa: alguien pone pytest==9.1.1 en su requirements, se siente reproducible, y un día el CI da rojo porque pluggy —que nunca nombró— saltó a una versión con un cambio de comportamiento. Por qué pasa: es fácil olvidar que cada dependencia arrastra un árbol de dependencias transitivas que también tienen versiones. Cómo detectarlo: corre pip freeze y cuenta; si instalaste cuatro paquetes y freeze lista diez, hay seis versiones que no estás controlando. Cómo corregirlo: para pipelines estrictos, congela la foto completa de pip freeze (o usa un archivo de lock de pip-tools/Poetry/uv); para proyectos con dependencias estables como Reservo, pinnear lo de alto nivel basta, pero sabiendo que las transitivas quedan a merced de pip.
Reproducir sin un entorno virtual limpio. Qué pasa: alguien intenta reproducir el entorno del CI instalando requirements-dev.txt sobre su entorno global, que ya tiene versiones viejas de medio mundo. La instalación "funciona", pero su entorno no es el del runner —tiene contaminación que el runner no—, así que la "reproducción" miente. Por qué pasa: crear un venv se siente como un paso extra prescindible. Cómo detectarlo: si tu pip freeze lista paquetes que requirements-dev.txt no menciona ni arrastra, estás en un entorno sucio. Cómo corregirlo: siempre un venv nuevo (python -m venv .venv) antes de reproducir; es la única forma de igualar el punto de partida limpio del runner.
Confundir "pasa en mi máquina" con "es reproducible". Qué pasa: alguien insiste en que su suite "funciona" porque pasa en su terminal, sin darse cuenta de que pasa gracias a versiones que solo él tiene. El CI, con otras versiones, da rojo, y el debate se vuelve "pero en mi máquina funciona" contra "pues aquí no". Por qué pasa: la máquina propia es un entorno acumulado durante meses, lleno de versiones específicas que uno no recuerda haber elegido. Cómo detectarlo: si no puedes recrear tu entorno desde cero en un venv limpio con un requirements y obtener el mismo resultado, tu "funciona" depende de algo no capturado. Cómo corregirlo: la disciplina de esta lección —fijar versiones, reproducir en venv limpio—, que convierte "funciona en mi máquina particular" en "funciona en cualquier máquina que instale esta foto", que es lo único que el CI puede prometer.
Ejercicios
Ejercicio 1 — >= vs ==. Un compañero defiende usar pytest>=9.0 en vez de pytest==9.1.1 "para tener siempre lo último". Explica qué gana y qué pierde con >=, y por qué en un pipeline de CI el == suele ganar el debate.
Ver solución
Con pytest>=9.0 gana frescura automática: cada instalación trae la versión más nueva disponible dentro del rango, así que las mejoras y parches llegan sin editar el archivo. Lo que pierde es exactamente lo que un pipeline necesita: el determinismo. Con un rango, dos instalaciones en días distintos pueden traer versiones distintas —hoy 9.1.1, mañana 9.2.0 si sale—, así que el mismo requirements produce entornos distintos, y un rojo nuevo podría deberse a un cambio de versión y no a un cambio de código. Peor: tu máquina (que instaló ayer) y el runner (que instala hoy) podrían tener versiones distintas del mismo rango, reabriendo la brecha del "en mi máquina funciona".
En CI el == gana porque el valor central de un pipeline es que sea reproducible: la misma entrada (código + requirements) debe dar la misma salida (verde/rojo, cobertura, tiempos) sin importar cuándo ni dónde corra. El == clava la versión, así que un rojo es siempre por el código, no por una dependencia que se movió sola. La frescura se maneja aparte, con intención: actualizas la versión pinneada cuando decides hacerlo (leíste el changelog, corriste la suite contra la nueva), no como efecto secundario de que hoy tocó reinstalar. Herramientas como Dependabot automatizan proponer esas subidas como pull requests que el propio CI valida —frescura controlada, no frescura al azar—.
Ejercicio 2 — El rojo transitivo. Reservo lleva meses en verde. Nadie tocó el código ni el requirements-dev.txt, pero hoy el CI dio rojo con un error interno de pytest-xdist. pip freeze en el runner muestra execnet==2.2.0, mientras que tu requirements-dev.txt solo pinnea pytest-xdist==3.8.0. Explica qué pasó y cómo lo previenes.
Ver solución
Lo que pasó: execnet es una dependencia transitiva de pytest-xdist —xdist la usa para comunicarse entre los procesos worker—, y tú nunca la pinneaste. Tu requirements-dev.txt fija pytest-xdist==3.8.0, pero deja libre a execnet, así que el runner instaló la versión más nueva disponible el día de hoy (2.2.0), que resultó traer un cambio de comportamiento incompatible con cómo xdist la usaba. El código no cambió, el requirements de alto nivel no cambió, pero el entorno cambió por debajo, en una dependencia que no estabas controlando. Ese es el rojo transitivo: nace en una versión que nunca nombraste.
Cómo se previene: congelando la foto completa. Si tu archivo de instalación fuera la salida de pip freeze —con execnet==2.1.2 explícito, junto a pluggy, iniconfig y las demás transitivas—, el runner instalaría execnet==2.1.2 como tú, y el salto a 2.2.0 no habría ocurrido hasta que tú, deliberadamente, actualizaras la foto y corrieras la suite para validar. La disciplina: para un pipeline que no quiere sorpresas, pinnea todo el árbol, no solo las hojas de alto nivel. Un archivo de lock (pip-tools, Poetry, uv) automatiza generar y mantener esa foto completa, resolviendo el árbol una vez y clavándolo entero.
Ejercicio 3 — ¿Un archivo o dos? Un compañero propone tener un solo requirements.txt con todo —pytest, pytest-cov, pytest-xdist, pytest-rerunfailures— y borrar requirements-dev.txt, "para simplificar". Para Reservo, ¿es defendible? ¿Y para una app web con dependencias de producción reales? Justifica.
Ver solución
Para Reservo, es defendible pero poco importante. Reservo no tiene dependencias de producción (es stdlib pura), así que su requirements.txt "de producción" tendría solo pytest, y la diferencia entre uno y dos archivos es casi cosmética: no hay un "entorno de producción" de Reservo que quieras mantener liviano. Un solo archivo con las cuatro herramientas funcionaría sin daño real.
Para una app web con dependencias reales, separar en dos archivos sí importa, y borrar requirements-dev.txt sería un error. La razón es el entorno de producción: cuando despliegas la app, quieres instalar solo lo que necesita para correr —el framework web, el cliente de base de datos—, no el instrumental de testing —pytest, cobertura, xdist—. Meter las herramientas de test en el requirements.txt de producción infla la imagen de despliegue con paquetes que el servidor nunca usa, aumenta la superficie de seguridad (más código instalado = más cosas que pueden tener vulnerabilidades), y confunde el contrato de "qué necesita esto para funcionar". La separación estándar es: requirements.txt = lo mínimo para correr en producción; requirements-dev.txt = eso más las herramientas de desarrollo y CI (normalmente empieza con -r requirements.txt para incluirlo y luego suma las de test). El CI instala el de dev (necesita probar); el despliegue instala el de producción (solo necesita correr). La lección: separar no es burocracia, es mantener honesto el contrato de cada entorno.
Resumen y siguiente paso
En esta lección apilaste la segunda capa del pipeline: la reproducibilidad. Cerraste la grieta del piso —que instalaba sin fijar todo— entendiendo que un pipeline solo es confiable si es determinista, y una instalación es determinista solo si cada versión está clavada. Separaste el requirements.txt de producción del requirements-dev.txt que trae las herramientas de CI (cada una una etapa futura: cov, xdist, rerunfailures), y viste por qué el pin exacto == vence al rango >= en un pipeline: la misma entrada debe dar la misma salida, sin que una dependencia se mueva sola.
Usaste pip freeze para ver la foto completa del entorno —diez paquetes donde pediste cuatro— y entendiste que las dependencias transitivas (execnet, pluggy) son las que más silenciosamente se desincronizan. Aprendiste el patrón de reproducción —venv limpio, instalar desde la lista pinneada, confirmar con pip freeze— y por qué esta capa hace legítimos los números de las lecciones siguientes: el 88% de cobertura, los tiempos de xdist, los veredictos de la matriz solo significan algo si se miden sobre un entorno clavado.
Antes de avanzar deberías poder: explicar por qué == vence a >= en CI; distinguir requirements.txt de requirements-dev.txt y decir qué va en cada uno; usar pip freeze para ver el árbol completo y reconocer las transitivas; y reproducir el entorno del CI en un venv limpio.
Lo que sigue, en la lección 4, es la tercera capa: la matriz de versiones. Hasta ahora tu pipeline corre en un solo Python (3.14) sobre un entorno reproducible —bien—, pero un solo entorno verde afirma un solo entorno, y Reservo, como librería, promete varias versiones a sus usuarios. Vas a envolver el workflow base en una strategy.matrix que lo corra en 3.11, 3.12 y 3.13 a la vez, cada celda con su propia foto determinista, y verás la feature dependiente de versión (report_pages) comportarse distinto según la versión que la ejecuta. La reproducibilidad de esta lección es lo que hace que cada celda de esa matriz sea un experimento limpio.
Recursos
- pip: Requirements files — el formato del archivo de requerimientos: cómo se pinnea con
==, cómo funcionan los rangos, y por qué el orden y los comentarios importan. La referencia canónica de lo que escribiste en esta lección. - pip freeze — documentación de pip — el comando que toma la foto exacta del entorno, incluyendo las dependencias transitivas. Léelo para entender por qué su salida es directamente instalable como un
requirements. - Managing dependencies (Python Packaging User Guide) — el panorama de herramientas de lock (
pip-tools,Poetry,uv) que congelan el árbol completo de forma automática, el paso siguiente cuando pinnear a mano se queda corto. - Keeping your dependencies updated — Dependabot — cómo automatizar la frescura controlada: subir versiones pinneadas como pull requests que el propio CI valida, en vez de dejar rangos abiertos. La otra mitad del debate
==vs>=.