Módulo 3: Reproducir el fallo de CI en tu máquina

5. Un venv limpio que replica al de CI

Descripción

Ya sabes qué difiere entre CI y tu máquina (la brecha de entorno, lección 3) y por qué las dependencias divergen (los rangos contra los pins, lección 4). Esta lección te da la herramienta que convierte ese saber en acción: el entorno virtual limpio, un venv recién creado dentro de tu máquina donde solo existe lo que instalas a propósito —una réplica de la cocina vacía de CI, montada en tu propio disco—. Al terminar vas a saber crear uno con python -m venv, activarlo, instalar en él las versiones exactas que usó CI con pip install -r requirements.txt, hacer que tu proyecto sea importable dentro, y correr la suite ahí para reproducir el rojo a voluntad.

La idea es sencilla y poderosa: en vez de pelear contra el sedimento de tu máquina —todas las librerías viejas y variables acumuladas que la hacen "demasiado amable"—, lo esquivas por completo creando un rincón nuevo y vacío. Ese rincón parte de cero, igual que el runner de CI en cada corrida, así que si instalas en él exactamente lo que instaló CI, obtienes su misma cocina. Y con la misma cocina, el mismo código produce el mismo resultado —incluido el rojo que no lograbas ver—.

Conexión con el módulo. La lección 4 te dio el pin (las versiones exactas); esta te da el lugar limpio donde aplicarlo. Es el puente entre "sé qué versión usó CI" y "reproduje su fallo": el venv limpio es donde el pin se vuelve una reproducción real. Prepara la lección 6 (las diferencias que un venv limpio no cubre: variables, tz, archivos) y la lección 7 (el método completo, del que el venv limpio es el paso central). Sobre el requirements.txt de Reservo y su fallo de pytz.

La analogía: la cocina de pruebas

Un chef que quiere reproducir el problema de un cliente ("tu platillo me cayó mal") no lo intenta en su cocina de siempre, llena de sus ingredientes y sus mañas. Monta una cocina de pruebas: una mesa vacía, y sobre ella pone solo lo que el cliente dijo haber usado —esta marca de aceite, esta harina, este método—. Nada más. Si en esa mesa controlada el platillo también sale mal, reprodujo el problema y puede estudiarlo. Si hubiera cocinado en su cocina de siempre, cualquier ingrediente "de la casa" —una especia que él siempre agrega sin pensar— podría enmascarar el problema o crear uno nuevo, y nunca sabría qué causó qué.

El venv limpio es esa cocina de pruebas. Tu máquina de siempre tiene demasiado: pytz vieja instalada para otro proyecto, variables exportadas hace meses, paquetes globales que ni recuerdas. Cualquiera de ellos puede estar tapando o falseando el fallo que persigues. Un venv nuevo es una mesa vacía dentro de tu máquina: solo tiene lo que pones a propósito. Pones exactamente lo que CI instaló, corres, y observas. Controlas las variables una por una porque partiste de cero, no de un revoltijo acumulado.

Dicho directo:

Un entorno virtual limpio es una cocina de pruebas vacía dentro de tu máquina: parte de cero, como el runner de CI, y solo contiene lo que instalas a propósito. Instalar en él las versiones exactas de CI reproduce la cocina de CI —y con ella, su resultado, incluido el fallo—.

Qué es un entorno virtual y por qué parte de cero

Un entorno virtual (venv) es una carpeta que contiene una copia (o enlace) del intérprete de Python y su propio site-packages —el lugar donde se instalan las librerías—, aislado del Python del sistema y de los demás proyectos. Cuando lo "activas", tu terminal usa ese Python y ese site-packages: pip install instala ahí dentro, import busca ahí dentro. Es un compartimento estanco. Instalar algo en un venv no toca el resto de tu máquina, y —la clave para nosotros— un venv recién creado está casi vacío: no hereda las librerías que tienes instaladas globalmente ni en otros venvs.

Eso último es lo que lo hace la herramienta perfecta para reproducir. Un venv nuevo no tiene tu pytz 2022.1 sedimentada. No tiene nada, salvo pip para poder instalar. Es una hoja en blanco. Cuando le instalas el requirements.txt de CI, obtienes exactamente el árbol de dependencias que CI obtuvo —ni más (no hay sedimento que sobre) ni menos (instalas todo lo declarado)—. La cocina queda idéntica en la capa que más rompe: la de las librerías.

Los comandos base, que vas a repetir toda tu vida:

python3.14 -m venv fresh-venv          # crear el venv (una carpeta llamada fresh-venv)
source fresh-venv/bin/activate         # activarlo (en macOS/Linux; en Windows: fresh-venv\Scripts\activate)
pip install -r requirements.txt        # instalar las dependencias declaradas
pytest                                  # correr la suite en este entorno limpio
deactivate                              # salir del venv cuando termines

Un par de detalles que importan. python3.14 -m venv fija qué versión de Python tendrá el venv: si quieres replicar el Python 3.14 de CI, crea el venv con un python3.14, no con cualquier python. (Replicar la versión exacta del intérprete es tema de la lección 7 y del módulo 4, pero empieza aquí: el venv hereda la versión del Python con que lo creas.) Y pip install -r requirements.txt es el mismo comando que corre el step de instalación de tu workflow de CI —lo estás imitando literalmente—.

Qué contiene un venv recién creado (nada, casi)

Vale la pena ver lo vacío que está un venv nuevo, porque esa vacuidad es justo su valor. Recién creado, antes de instalar nada, un venv contiene solo pip.

Qué esperar. Creas el venv y preguntas qué hay instalado:

$ python3.14 -m venv fresh-venv
$ fresh-venv/bin/python -m pip list
Package Version
------- -------
pip     25.2

Eso es todo: pip 25.2 y nada más. Ni pytz, ni pytest, ni ninguna de las librerías que tienes regadas por tu máquina. Confírmalo preguntando directamente si ve tu pytz sedimentada:

$ fresh-venv/bin/python -c "import importlib.util as u; print('ve pytz?', u.find_spec('pytz') is not None)"
ve pytz? False

False: el venv limpio no ve la pytz que tienes instalada globalmente. Está aislado. Esa es la diferencia con correr python a secas en tu máquina, que sí vería todo tu sedimento. Partir de este False —de esta hoja en blanco— es lo que te garantiza que, cuando instales el requirements.txt de CI, tendrás solo lo que CI tiene, sin contaminación.

Compara esto mentalmente con el runner de CI: su máquina virtual efímera arranca igual de vacía, y el step de pip install le pone exactamente lo declarado. Tu venv limpio es la versión local de esa misma máquina efímera. La única diferencia es que la tuya vive en una carpeta que puedes borrar (rm -rf fresh-venv) y volver a crear cuando quieras.

Hacer que tu proyecto sea importable

Hay un paso que la gente olvida y que produce un ModuleNotFoundError: No module named 'reservo' desconcertante: en un venv limpio, tus dependencias de terceros (pytz) se instalan con pip, pero tu propio código (reservo/) no está instalado —es solo una carpeta en tu disco—. Para que from reservo.localtime import local_start_hour funcione dentro del venv, Python tiene que poder encontrar la carpeta reservo/.

Hay dos formas, y conviene conocer ambas:

  1. Correr pytest desde la raíz del proyecto. Si te paras en la carpeta que contiene reservo/ y corres pytest desde ahí, pytest agrega la raíz al camino de imports, y reservo se encuentra. Es lo más simple para un proyecto chico, y es lo que asumen los ejemplos de este módulo. (Que corras desde la raíz o desde una subcarpeta es, además, el sospechoso #7 de la lección 2 —el directorio de trabajo—; correr siempre desde la raíz lo descarta.)

  2. Instalar el proyecto en modo editable. Si el proyecto tiene un pyproject.toml (o setup.py), pip install -e . lo instala "enlazado": el venv sabe dónde vive reservo/ y lo importa como cualquier librería, corras desde donde corras. Es lo que hacen los proyectos serios, y a menudo lo que hace CI. El -e (editable) significa que sigue apuntando a tu carpeta, así que ves tus cambios sin reinstalar.

Para reproducir el fallo de Reservo nos basta la opción 1 —correr pytest desde la raíz—, pero si tu CI hace pip install -e ., replícalo: es parte de igualar la cocina. La regla general del módulo aplica también aquí: haz lo mismo que hace CI, incluido cómo pone tu código a disposición de los tests.

Ejemplo trabajado: reproducir el rojo en un venv limpio

Juntemos todo para reproducir el fallo de Reservo. Sabes (por leer el log de CI, lección 2) que CI corre Python 3.14.0 e instaló pytz 2026.3.post1. El plan: crear un venv limpio con Python 3.14, pinnear pytz a esa versión exacta, instalar, y correr el mismo comando.

Primero, el requirements.txt pinneado a lo que usó CI (en vez del >= ambiguo del proyecto):

# requirements-repro.txt — pinneado a lo que instaló CI
pytz==2026.3.post1

Luego, el venv limpio y la instalación:

$ python3.14 -m venv repro-venv
$ repro-venv/bin/python -m pip install -r requirements-repro.txt pytest==9.1.1
$ repro-venv/bin/python -c "import pytz; print('pytz:', pytz.__version__)"
pytz: 2026.3.post1

La cocina quedó igual a la de CI en la capa de dependencias: pytz 2026.3.post1, Python 3.14.0, pytest 9.1.1. Ahora corre el mismo comando que corre CI, desde la raíz del proyecto:

Qué esperar.

$ repro-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
E        +  where 15 = local_start_hour(Booking(id='bk-1', ...), 'America/Mexico_City')

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.03s

Rojo. Reprodujiste el fallo de CI en tu máquina. Ese AssertionError: assert 15 == 16 es el mismo que veías en el log de CI y que en tu entorno de siempre no aparecía. Ya no es un fantasma: lo tienes vivo, en tu terminal, cuando quieras. Este es el hito que desbloquea todo lo demás —ahora sí puedes diagnosticar por qué (guía hermana), o decidir que el número esperado envejeció y corregirlo (mini-proyecto)—.

Para cerrar el círculo y demostrar que el venv limpio es determinista —que reproduce cualquier resultado, no solo el rojo—, monta otro venv pinneado a la versión vieja (la 2022.1, la de tu máquina de siempre) y corre lo mismo:

$ python3.14 -m venv old-venv
$ old-venv/bin/python -m pip install pytz==2022.1 pytest==9.1.1
$ old-venv/bin/python -c "import pytz; print('pytz:', pytz.__version__)"
pytz: 2022.1
$ old-venv/bin/python -m pytest test_localtime.py -q
1 passed in 0.02s

Verde. Con la pytz 2022.1 pinneada, el mismo test pasa —reproduce la cocina del dev—. Dos venvs limpios, misma máquina, mismo código, mismo comando: uno rojo y otro verde, y la única diferencia entre ellos es el número de versión que pinneaste. Eso es control total sobre la capa de dependencias: puedes reproducir a voluntad el entorno de CI (rojo) o el del dev (verde), y comparar. El venv limpio convirtió una divergencia misteriosa en un interruptor que enciendes y apagas.

Profundización: qué cubre el venv limpio y qué no

El venv limpio es la herramienta central de la reproducción, pero es honesto marcar sus límites, porque no cierra toda la brecha de entorno —solo algunas capas—.

Lo que sí cubre:

  • La capa de dependencias: aislado del sedimento, instala exactamente lo declarado. Esta es la que más rompe, y la que el venv resuelve de raíz.
  • La capa del intérprete: si creas el venv con la versión de Python correcta, replicas esa capa (parcialmente; la matriz del módulo 4 la sistematiza).
  • Buena parte de la capa del directorio de trabajo: al forzarte a correr desde la raíz o a instalar el proyecto, ordena cómo se resuelven los imports.

Lo que no cubre por sí solo:

  • La capa de variables de entorno: un venv no borra las variables de tu shell. Si el fallo depende de RESERVO_TAX_PERCENT, el venv limpio seguirá viéndola si está exportada. Hay que gestionarlas aparte (lección 6).
  • La capa del sistema operativo: un venv en tu Mac sigue siendo macOS; no se vuelve el Linux de CI. Para diferencias de SO se necesitan contenedores o la matriz (más allá de este módulo, salvo mención).
  • La capa de la zona horaria del sistema (la variable TZ): el venv no la cambia; la controlas con variables (lección 6). La data de tz sí la controla, porque viaja en pytz, que es una dependencia.
  • La capa del sistema de archivos: un venv no borra los archivos no commiteados de tu disco; si tu test lee uno que solo tienes tú, seguirá leyéndolo.

Por eso el venv limpio es necesario pero no siempre suficiente: cierra la capa de dependencias (la más común) de un tajo, pero las capas invisibles —variables, tz del sistema, archivos— piden atención propia. La lección 6 se ocupa justo de esas, y la lección 7 las junta todas en un método donde el venv limpio es el paso central pero no el único. Saber qué cubre y qué no te evita el error de crear un venv perfecto y desconcertarte porque el fallo sigue sin reproducirse: si es de una variable, el venv por sí solo no lo iba a atrapar.

Errores comunes

Reproducir en tu entorno de siempre en vez de en un venv limpio. Qué pasa: instalas la versión de pytz de CI encima de tu Python global, con todo tu sedimento presente, y el resultado es confuso —a veces reproduce, a veces no, y no sabes qué más influye—. Por qué pasa: crear un venv se siente como un paso extra innecesario. Cómo detectarlo: si corriste pip install sin haber activado (o apuntado a) un venv nuevo, tocaste tu entorno global. Cómo corregirlo: siempre reproduce en un venv recién creado. La vacuidad del venv es lo que aísla la variable que persigues; en tu entorno global, cien cosas más pueden interferir.

Crear el venv con la versión de Python equivocada. Qué pasa: reproduces con un venv creado con python3.12 cuando CI usa 3.14 (o viceversa), y o no reproduces el fallo, o reproduces otro distinto. Por qué pasa: uno crea el venv con "el python que tenga a mano" sin fijarse en la versión. Cómo detectarlo: si python --version dentro del venv no coincide con la cabecera del log de CI, tienes la capa del intérprete desalineada. Cómo corregirlo: crea el venv con el ejecutable de la versión exacta de CI (python3.14 -m venv ...), y verifica con python --version dentro. El venv hereda la versión del Python con que lo creaste.

Olvidar que tu propio código no se instala solo. Qué pasa: montas un venv limpio, instalas pytz y pytest, corres desde otra carpeta y obtienes ModuleNotFoundError: No module named 'reservo', y crees que rompiste algo. Por qué pasa: es fácil suponer que si pytz se importa, reservo también, pero reservo es tu código, no una librería instalada. Cómo detectarlo: el error menciona tu paquete (reservo), no una librería de terceros. Cómo corregirlo: corre pytest desde la raíz del proyecto (donde vive reservo/), o instala el proyecto con pip install -e . si tiene pyproject.toml. Replica cómo CI pone tu código a disposición de los tests.

Ejercicios

Ejercicio 1 — El venv correcto. El log de CI empieza con platform linux -- Python 3.14.0, pytest-9.1.1 e instaló pytz==2026.3.post1. Escribe la secuencia de comandos que montaría un venv limpio para reproducir su entorno (crear, instalar, verificar versiones), asumiendo que corres los tests desde la raíz del proyecto.

Ver solución
# 1. Crear el venv con la MISMA versión de Python que CI (3.14)
python3.14 -m venv repro-venv

# 2. Instalar exactamente lo que instaló CI (pytz pinneada + pytest)
repro-venv/bin/python -m pip install pytz==2026.3.post1 pytest==9.1.1

# 3. Verificar que las versiones coinciden con el log de CI
repro-venv/bin/python --version                       # -> Python 3.14.0
repro-venv/bin/python -c "import pytz; print(pytz.__version__)"   # -> 2026.3.post1

# 4. Correr el mismo comando, desde la raíz del proyecto
repro-venv/bin/python -m pytest test_localtime.py -q

Notas: se crea con python3.14 para igualar la capa del intérprete; se pinnea pytz a la versión exacta del log; se verifica antes de correr (para no reproducir con una cocina distinta sin darte cuenta); y se corre desde la raíz para que reservo sea importable. La única capa que este venv no iguala automáticamente es el SO (sigues en tu máquina, no en linux), pero para un fallo de pytz eso no importa.

Ejercicio 2 — El venv que no reproduce. Montaste un venv limpio con pytz==2026.3.post1 y Python 3.14, corriste la suite y el test de la hora local... pasó. El fallo no se reprodujo. Da al menos dos hipótesis de qué capa podría estar desalineada, y cómo la verificarías.

Ver solución

Si con la pytz de CI el fallo no se reproduce, alguna otra capa difiere. Hipótesis:

  1. La versión de pytz no es realmente la de CI. Quizás el log de CI decía otra versión, o el venv resolvió a una distinta. Verifica con pytz.__version__ dentro del venv y compáralo carácter por carácter con el log de CI.
  2. La zona horaria del sistema (TZ) difiere. El cálculo de hora local no depende solo de pytz, sino también de cómo se construye el datetime. Si tu shell tiene TZ=America/Mexico_City y el runner otra, o si el test depende de la tz del sistema, el resultado cambia. Verifica con echo $TZ y printenv TZ, y prueba forzando la misma (lección 6).
  3. Una variable de entorno relacionada. Alguna variable que el código lea y que tú tengas y CI no (o al revés). Compara env entre tu shell y lo que el workflow define.
  4. La versión de Python no coincide exactamente. 3.14.0 vs 3.14.1 podría (rara vez) importar. Verifica python --version contra la cabecera del log.

La lección de fondo: el venv limpio cierra la capa de dependencias, pero si el fallo vive en otra capa (variables, tz del sistema), el venv por sí solo no lo atrapa. Ahí entra la lección 6.

Ejercicio 3 — Determinismo del venv. Explica, con el ejemplo de los dos venvs (uno con pytz==2022.1, otro con pytz==2026.3.post1), qué significa que el venv limpio sea "determinista" y por qué eso es exactamente lo que necesitas para reproducir un fallo.

Ver solución

"Determinista" significa que el resultado depende solo de lo que pusiste a propósito en el venv, y no de sedimento oculto ni del azar. Con el venv pinneado a pytz==2022.1, la suite da verde siempre; con el pinneado a pytz==2026.3.post1, da rojo siempre. El color no baila entre corridas: es una función pura de la versión que instalaste. Puedes montar el venv, destruirlo (rm -rf), y volver a montarlo idéntico cuantas veces quieras, y obtener el mismo resultado.

Por qué eso es justo lo que necesitas para reproducir: un fallo que quieres arreglar tiene que ocurrir a voluntad, no de vez en cuando. Si el venv fuera no determinista (dependiera de tu sedimento), a veces reproducirías el fallo y a veces no, y nunca sabrías si tu arreglo funcionó o si simplemente esta vez "tocó verde". El determinismo del venv convierte el fallo en un interruptor —enciendes el rojo instalando la versión de CI, apagas instalando la vieja— y solo con un interruptor confiable puedes trabajar: cambias el código, corres, y el color te dice la verdad sin ruido.

Resumen y siguiente paso

En esta lección montaste la herramienta central de la reproducción: el venv limpio, una cocina de pruebas vacía dentro de tu máquina que parte de cero como el runner de CI. Viste con salida real lo vacío que arranca (pip list muestra solo pip 25.2; no ve tu pytz sedimentada), aprendiste a crearlo con python3.14 -m venv (heredando la versión de Python correcta), a instalar las versiones exactas de CI con pip install -r requirements.txt, y a hacer tu proyecto importable (correr desde la raíz o pip install -e .). Y reprodujiste el fallo de Reservo: con pytz==2026.3.post1 pinneada, el mismo AssertionError: assert 15 == 16 de CI apareció en tu terminal —el fantasma, hecho carne—.

Cerraste el círculo mostrando el determinismo del venv: un venv con la versión vieja da verde siempre, uno con la nueva da rojo siempre; el color es una función pura de lo que instalaste, un interruptor que enciendes y apagas. Y marcaste los límites honestos: el venv cierra la capa de dependencias (la más común) de un tajo, pero no borra por sí solo las capas invisibles —variables, tz del sistema, archivos no commiteados—.

Antes de avanzar deberías poder: crear un venv con una versión específica de Python; explicar por qué un venv nuevo no ve tu sedimento; hacer tu proyecto importable dentro; reproducir un fallo instalando la versión exacta de CI; y decir qué capas el venv no cubre.

Lo que sigue es cazar justo esas capas que el venv no cubre. La lección 6 va tras las diferencias ocultas —variables de entorno, zona horaria del sistema, orden de tests, archivos que solo existen en tu disco—: cómo detectarlas comparando los dos entornos y cómo replicarlas para que la reproducción sea completa cuando el fallo no era (solo) de una dependencia.

Recursos