Módulo 4: La matriz — múltiples versiones y entornos

1. Presentación del módulo: la matriz

Descripción

Hasta aquí, tu pipeline tenía una virtud y un punto ciego. La virtud: corría la suite de Reservo automáticamente en cada push, sin que nadie se acordara de hacerlo. El punto ciego: la corría en un solo entorno. Una versión de Python —la que el runner traía por defecto—, un sistema operativo —normalmente Linux—, y punto. Cuando ese único job daba verde, tú leías "la suite pasa". Pero lo que en realidad decía era más humilde: "la suite pasa en ese entorno". Y ahí está el problema que este módulo resuelve, porque tu código casi nunca vive solo en ese entorno.

Al terminar esta lección vas a entender qué es la estrategia de matriz de CI y por qué existe: una tabla de entornos —varias versiones de Python, varios sistemas operativos— que GitHub Actions expande en un job por combinación, todos corriendo en paralelo, cada uno con su propio veredicto verde o rojo. En vez de un check que dice "pasa", tienes N checks que dicen "pasa en 3.11", "pasa en 3.12", "pasa en 3.13", y si uno se pone rojo, sabes exactamente en qué versión se rompió sin adivinar. Vas a ver el esqueleto del YAML que la define, cómo se lee el resultado, y —esto es lo que separa a quien copia una matriz de internet de quien la diseña— cuándo la matriz paga y cuándo es puro ruido y costo.

Conexión con el módulo: esta lección es el mapa. Aquí instalas la idea —por qué un solo verde no basta y qué es una matriz— y la ves latir una vez con una demo local real. La lección 2 abre en canal qué cambia entre entornos: features de la librería estándar que aparecen en una versión y no en la anterior, separadores de ruta que difieren por sistema operativo. La 3 escribe la matriz de versiones de Python en el YAML, pieza por pieza. La 4 le suma la dimensión del sistema operativo y multiplica la cuadrícula. La 5 la afina con include, exclude y fail-fast. La 6 te enseña a leer los N resultados y localizar en qué celda vive un bug. La 7 es la decisión de negocio: cuándo esta máquina paga y cuándo estorba. Y la 8, el mini-proyecto, te pone a configurar la matriz de tres versiones para Reservo de principio a fin.

Una nota sobre la frontera, porque este módulo se apoya en los tres anteriores y no los repite. Reproducir localmente un fallo de CI fue el módulo 3: aquí, cuando una celda de la matriz se ponga roja, vamos a saber qué versión igualar para reproducirla, pero la mecánica de la reproducción y la diagnosis a fondo ya la tienes. Acelerar el CI —cachear dependencias, paralelizar— es el módulo 5, que llega justo después, y no es casualidad: una matriz multiplica el trabajo, así que la velocidad importa más que nunca. Aquí el foco es uno solo: la matriz. Qué es, cómo se escribe, cómo se lee, y cuándo vale la pena.

El restaurante que solo probó su platillo en una estufa

Piensa en un chef que inventa un platillo nuevo. Lo cocina en su cocina, en su estufa de gas, con sus ollas, y le queda perfecto. Lo agrega al menú. La primera semana llegan quejas raras: en la sucursal del centro el platillo sale crudo por dentro, en la del norte se quema. El chef no entiende: "en mi cocina funciona".

El problema no es la receta. Es que cada sucursal cocina en un entorno distinto. Una tiene estufa eléctrica, que calienta más lento; otra tiene una de inducción, que calienta a otra curva; la del norte está a mil metros más de altura, donde el agua hierve a menor temperatura. La receta que el chef probó en una estufa asumía, sin darse cuenta, cosas de esa estufa. Nunca la probó en las demás. Y "funciona en mi cocina" resultó ser una afirmación mucho más estrecha de lo que él creía.

La solución de un chef serio no es cruzar los dedos. Es probar la receta en cada tipo de estufa antes de mandarla al menú: gas, eléctrica, inducción, a distintas altitudes. Si sale bien en las cinco, la publica con confianza. Si sale mal en la de inducción, lo descubre en su cocina de pruebas —no en la cara de un cliente— y ajusta la receta o anota "esta no va para inducción".

Una matriz de CI es exactamente esa cocina de pruebas con cinco estufas. Tu código es la receta. Cada versión de Python y cada sistema operativo es un tipo de estufa. La matriz cocina tu suite de Reservo en todas las estufas que te importan, a la vez, y te entrega un veredicto por cada una. "Funciona en mi máquina" deja de ser una esperanza y se vuelve una tabla de resultados: funciona en gas, en eléctrica, en inducción; falla a gran altitud, y ya sabes por qué.

Una matriz de CI corre tu misma suite en varios entornos —versiones de Python, sistemas operativos— y te da un veredicto por cada combinación. Convierte "funciona en mi máquina" en "funciona en estas seis, falla en esta, y sé cuál".

Reservo, tal como lo dejamos — y una feature nueva que delata la versión

Seguimos con Reservo, el sistema de reservas de salas de un coworking que venimos probando desde el primer módulo. Lógica pura de Python: sin base de datos, sin red, sin relojes escondidos. Sus piezas, por si necesitas refrescar:

  • Room (id, name, capacity, hourly_cents), Member (id, name, tier: "basic" o "pro"), Booking (con su campo price_cents, el start, el end como rango medio-abierto [start, end), y su status).
  • Las funciones núcleo: price_cents(room, member, hours), refund_cents(booking, price_paid_cents, now), overlaps, is_available, book, y el Calendar que guarda las reservas en memoria.
  • Los números-ancla, el checksum de toda la guía: basic 3 h → 7500, pro 3 h → 6000 (20% de descuento), y el reembolso sobre 6000 pagados: 6000 si cancelas 72 h antes (≥ 48 h, 100%), 3000 a 36 h (24–48 h, 50%), 0 a 12 h (< 24 h).

Todo eso ya lo tienes probado. Para este módulo le agregamos a Reservo una función nueva que, a propósito, depende de la versión de Python: un reporte diario que agrupa las reservas en páginas. La usamos porque necesitamos algo que se comporte distinto según la versión, y esta es genuina, no un truco de laboratorio.

# reservo/reports.py
import sys

# `itertools.batched` es parte de la stdlib SOLO desde Python 3.12.
# En 3.11 y anteriores hay que traerlo a mano. Reservo elige la ruta
# segun la version que corre — y esa diferencia es justo lo que una
# matriz de CI existe para vigilar.
if sys.version_info >= (3, 12):
    from itertools import batched

    def report_pages(bookings, size):
        """Agrupa las reservas en paginas de `size` para el reporte diario."""
        return [list(page) for page in batched(bookings, size)]
else:
    def report_pages(bookings, size):
        """Fallback manual para Python < 3.12, donde no existe itertools.batched."""
        return [bookings[i:i + size] for i in range(0, len(bookings), size)]

Detente en el if sys.version_info >= (3, 12). sys.version_info es una tupla que Python te da con la versión que está corriendo en este preciso momento: en la máquina donde escribí esto vale (3, 14, 0). La comparación >= (3, 12) es True en 3.12, 3.13 y 3.14, y False en 3.11 y anteriores. Así, report_pages usa itertools.batched —una función de la librería estándar que apareció en Python 3.12— cuando está disponible, y una comprensión de listas a mano cuando no. El comportamiento visible es idéntico en ambas ramas; lo que cambia es el camino interno. Y ese "camino que cambia según la versión" es exactamente el tipo de cosa que un solo job verde no ve y que una matriz sí.

El primer contacto con skipif: la misma suite, distinto veredicto por versión

Para probar una feature que depende de la versión, pytest te da la herramienta exacta: @pytest.mark.skipif. Es un marcador que le pone una condición a un test: "si esta condición es verdadera, sáltate este test y no lo cuentes ni como pasado ni como fallado". Lo usamos para que un test que solo tiene sentido en 3.12+ no se ejecute —ni finja pasar, ni finja fallar— en versiones donde no aplica.

Aquí está el archivo de tests de la feature de versión. Léelo con calma: hay tres tests, y dos de ellos llevan un skipif con condiciones opuestas.

# tests/test_version_features.py
import sys

import pytest

from reservo.reports import report_pages


def test_report_pages_groups_bookings():
    # Esta regla vale en TODA version: 5 reservas en paginas de 2 -> [2, 2, 1].
    pages = report_pages(["b1", "b2", "b3", "b4", "b5"], 2)
    assert [len(p) for p in pages] == [2, 2, 1]


@pytest.mark.skipif(
    sys.version_info < (3, 12),
    reason="itertools.batched es parte de la stdlib solo desde Python 3.12",
)
def test_report_pages_uses_stdlib_batched():
    # En 3.12+ report_pages usa itertools.batched por dentro; aqui confirmamos
    # que el import de la stdlib esta disponible en la version que corre.
    from itertools import batched
    assert list(batched("abcde", 2)) == [("a", "b"), ("c", "d"), ("e",)]


@pytest.mark.skipif(
    sys.version_info >= (3, 12),
    reason="el fallback manual solo se ejercita en Python < 3.12",
)
def test_report_pages_manual_fallback_on_old_python():
    # Este test solo tiene sentido en 3.11 y anteriores, donde report_pages
    # usa la comprension manual en vez de itertools.batched.
    assert "batched" not in dir(__import__("itertools"))

Fíjate en las dos condiciones. test_report_pages_uses_stdlib_batched se salta cuando sys.version_info < (3, 12) —es decir, se salta en 3.11, y corre en 3.12, 3.13, 3.14—. test_report_pages_manual_fallback_on_old_python hace lo contrario: se salta cuando sys.version_info >= (3, 12) —corre en 3.11, y se salta en 3.12 y arriba—. Son espejos. En cualquier versión, uno de los dos corre y el otro se salta. Eso significa que la misma suite produce un resultado con un matiz distinto en cada celda de la matriz, y esa es la idea que quiero que sientas antes de escribir una sola línea de YAML.

Ejemplo trabajado: corre la suite en tu versión y mira qué se salta

Vamos a correr la suite completa de Reservo en la máquina donde estoy escribiendo, que tiene Python 3.14.0 y pytest 9.1.1. Esta corrida es la que un pipeline haría en una celda de la matriz; aquí la hacemos local para verla de verdad. La bandera -v (--verbose) lista cada test con su veredicto, en vez de resumirlos con puntos.

python -m pytest -v tests/

Qué esperar. En Python 3.14.0 sale esto, medido de verdad (no inventado):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/reservo-m4/.venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/reservo-m4
collected 9 items

tests/test_pricing.py::test_basic_three_hours PASSED                     [ 11%]
tests/test_pricing.py::test_pro_three_hours PASSED                       [ 22%]
tests/test_pricing.py::test_basic_one_hour PASSED                        [ 33%]
tests/test_refunds.py::test_full_refund_72h_before PASSED                [ 44%]
tests/test_refunds.py::test_half_refund_36h_before PASSED                [ 55%]
tests/test_refunds.py::test_no_refund_12h_before PASSED                  [ 66%]
tests/test_version_features.py::test_report_pages_groups_bookings PASSED [ 77%]
tests/test_version_features.py::test_report_pages_uses_stdlib_batched PASSED [ 88%]
tests/test_version_features.py::test_report_pages_manual_fallback_on_old_python SKIPPED [100%]

========================= 8 passed, 1 skipped in 0.01s =========================

Lee el resumen final despacio: 8 passed, 1 skipped. Ocho tests pasaron y uno se saltó. Mira cuál se saltó: test_report_pages_manual_fallback_on_old_python, el que solo tiene sentido en Python < 3.12. Como esta máquina corre 3.14, su condición sys.version_info >= (3, 12) es verdadera, y pytest lo saltó limpiamente. No falló —el fallback manual no aplica aquí, así que no habría nada que probar—, pero tampoco desapareció en silencio: pytest lo cuenta como skipped y hasta lo marca con SKIPPED en la lista.

Ahora piensa qué pasaría en otra celda de la matriz. Si este mismo archivo corriera en Python 3.11, las condiciones se invierten: test_report_pages_manual_fallback_on_old_python correría (y pasaría, porque en 3.11 itertools no tiene batched), mientras que test_report_pages_uses_stdlib_batched se saltaría (porque itertools.batched no existe ahí). El resumen seguiría diciendo 8 passed, 1 skipped, pero el test que se salta sería otro. Esa es la matriz en miniatura: la misma suite, corrida en versiones distintas, ejercitando caminos distintos, y cada celda contándote qué probó y qué no.

Si quieres ver por qué se saltó, pytest tiene una bandera para eso, -rs (report skipped), que imprime la razón que escribimos en el skipif:

=========================== short test summary info ============================
SKIPPED [1] tests/test_version_features.py:25: el fallback manual solo se ejercita en Python < 3.12
========================= 8 passed, 1 skipped in 0.01s =========================

Ahí está la reason que pusimos en el marcador, tal cual. Un skip sin razón es un misterio; un skip con razón es documentación. En la lección 2 vas a exprimir esta idea, y en el mini-proyecto la vas a entregar como parte del reporte.

Qué es y qué no es una matriz

Vale la pena dejar clara la anatomía antes de zambullirnos, para que las próximas lecciones caigan en su lugar.

Una matriz es, literalmente, una o más listas de valores que el CI multiplica para generar jobs. Una lista de tres versiones de Python genera tres jobs. Dos listas —tres versiones × tres sistemas operativos— generan nueve, uno por cada par. Cada job es una copia del mismo trabajo (checkout, instalar Python, instalar dependencias, correr pytest) parametrizada por su combinación. No escribes nueve jobs a mano; escribes las listas y el CI hace la multiplicación por ti. Esa multiplicación automática es todo el valor de la herramienta: expresas "quiero probar estas versiones en estos sistemas" en cuatro líneas, y obtienes la cuadrícula completa.

Lo que una matriz no es: no es magia que arregla incompatibilidades. Si tu código se rompe en 3.11, la matriz no lo repara; te lo muestra, que es distinto y muchísimo más útil. Tampoco es gratis: cada celda es un runner que consume minutos, y esos minutos se facturan y se acumulan. Una matriz de tres versiones por tres sistemas son nueve corridas de tu suite cada vez que alguien hace push. Por eso la lección 7 existe: la pregunta no es "¿puedo hacer una matriz enorme?" —sí puedes—, sino "¿qué matriz merece este proyecto?".

Y una honestidad de la guía, la misma de siempre: el workflow de CI se ejecuta de verdad en un runner de GitHub, que aquí no tenemos. Así que el YAML de la matriz lo vas a escribir y leer como contenido —te muestro cómo se ve y cómo se leería su log—, mientras que las corridas de pytest son reales, hechas en local con Python 3.14.0, porque tu máquina hace de una de las celdas de la matriz. Cuando cites "8 passed, 1 skipped", ese número lo medí ejecutando; cuando muestre un log de CI con tres jobs, ese es el formato honesto de cómo se vería, no una captura de un runner fantasma.

Errores comunes

Leer "el CI está verde" como "mi código funciona en todas partes". Qué pasa: el pipeline de un solo job da verde en Linux con Python 3.12, y el equipo concluye "listo, funciona". Un usuario con Python 3.11 lo instala y truena al primer import. Por qué pasa: un job verde solo afirma el entorno donde corrió, pero es fácil olvidar esa letra chica y leerlo como un veredicto universal. Cómo detectarlo: pregúntate "¿en qué exactamente está verde esto?". Si la respuesta es "en un entorno" y tu código lo usan en cinco, tienes un punto ciego. Cómo corregirlo: una matriz que cubra los entornos que de verdad importan. El resto del módulo es cómo.

Encender toda la matriz por reflejo, sin preguntarse si paga. Qué pasa: alguien copia una matriz de 3×3 de un tutorial para una app interna que solo corre en Linux con Python 3.12 en producción. Ahora cada push gasta nueve corridas, ocho de las cuales prueban entornos donde el código jamás se va a ejecutar. Por qué pasa: la matriz es fácil de encender y se siente "más completa". Cómo detectarlo: mira tus celdas y pregúntate por cada una "¿alguien va a correr mi código aquí de verdad?". Si la respuesta es no, esa celda es ruido y costo. Cómo corregirlo: prueba lo que envías más lo que prometes soportar, y nada más. La lección 7 te da la regla completa.

Confundir skip con pass o con fail. Qué pasa: alguien ve 1 skipped en el resumen y se asusta —"¿algo se saltó?, ¿está roto?"—, o al revés, lo ignora creyendo que "saltado" es lo mismo que "pasó". Por qué pasa: skipped es un tercer estado que no siempre se explica: no es verde ni rojo, es "este test no aplica en estas condiciones y lo dijimos a propósito". Cómo detectarlo: corre con -rs y lee la reason. Un skip con una razón clara ("el fallback solo aplica en < 3.12") es sano y esperado. Un skip sin razón, o uno que se saltó por accidente (un import que falló), es una señal para investigar. Cómo corregirlo: pon siempre reason en tus skipif, y trata el conteo de skips como información, no como alarma.

Ejercicios

Ejercicio 1 — Predice el skip en otra versión. En Python 3.14 corriste la suite y viste 8 passed, 1 skipped, y el que se saltó fue test_report_pages_manual_fallback_on_old_python. Sin correr nada, predice: si la misma suite corriera en Python 3.11, ¿cuántos pasan y cuántos se saltan, y cuál test se salta? Explica por qué en una frase.

Ver solución

Seguiría siendo 8 passed, 1 skipped, pero el test que se salta sería test_report_pages_uses_stdlib_batched, no el otro. Razón: en 3.11, la condición de ese test, sys.version_info < (3, 12), es verdadera (3.11 es menor que 3.12), así que se salta; y la condición del otro, sys.version_info >= (3, 12), es falsa, así que test_report_pages_manual_fallback_on_old_python corre y pasa (en 3.11 itertools de verdad no tiene batched). Los dos skipif son espejos: en toda versión, exactamente uno de los dos se salta. Lo que cambia entre celdas de la matriz no es el conteo total, sino cuál camino de código quedó ejercitado — y por eso vale la pena correr las dos versiones.

Ejercicio 2 — Traduce la analogía a la matriz. El chef del restaurante probaba su receta en cinco tipos de estufa (gas, eléctrica, inducción, y dos altitudes). Mapea cada elemento de esa analogía a su equivalente en una matriz de CI: (a) la receta, (b) un tipo de estufa, (c) probar en las cinco estufas antes de publicar, (d) el platillo que sale crudo solo en inducción.

Ver solución
  • (a) La receta → tu código (la suite de Reservo y el código que prueba). Es lo mismo en todas las estufas; lo único que cambia es dónde se cocina.
  • (b) Un tipo de estufa → un entorno, es decir, una combinación de versión de Python y sistema operativo. Gas ≈ "Python 3.12 en Linux"; inducción ≈ "Python 3.11 en Windows".
  • (c) Probar en las cinco antes de publicar → la matriz de CI, que corre la suite en todas las combinaciones a la vez antes de aprobar el cambio, en la cocina de pruebas (el runner) y no en la cara del cliente (producción).
  • (d) El platillo crudo solo en inducción → una celda roja de la matriz: un test que pasa en casi todos los entornos pero falla en uno específico. La matriz te dice cuál, igual que el chef supo que era la inducción y no las demás.

La moraleja de ambos lados: "funciona en mi máquina/cocina" es una afirmación sobre un entorno, no sobre todos. La matriz la convierte en una tabla honesta.

Ejercicio 3 — ¿Qué módulo resuelve esto? Para cada situación, di si la resuelve este módulo (la matriz) o si le toca a otro módulo de la guía, y nómbralo en una frase. (a) "La celda de 3.11 salió roja y quiero reproducir ese fallo en mi laptop." (b) "Mi matriz de 3×3 tarda 12 minutos y quiero que sea más rápida." (c) "Quiero correr la suite en Python 3.11, 3.12 y 3.13 a la vez." (d) "Un test pasa a veces y falla a veces, sin cambiar el código."

Ver solución
  • (a) Reproducir el fallo de la celda 3.11 en local → módulo 3 (reproducir un fallo de CI localmente). Este módulo te dice en qué versión falló, que es el primer dato; igualar esa versión en tu máquina y reproducir el fallo es la técnica del módulo 3. Aquí solo hacemos el puente.
  • (b) Que la matriz sea más rápida → módulo 5 (CI rápido: caché y paralelismo). Una matriz multiplica el trabajo, y acelerarla —cachear dependencias, paralelizar— es justo el tema que sigue. Este módulo arma la matriz; el 5 la agiliza.
  • (c) Correr la suite en tres versiones a la vez → este módulo, y en concreto la lección 3. Es la definición misma de una matriz de versiones de Python.
  • (d) Un test que pasa a veces y falla a veces → módulo 7 (flaky tests en CI). Un test no determinista es un flaky, y su tratamiento —retry, cuarentena, el fallo que solo ocurre en CI— es del módulo 7. Una matriz roja de forma consistente no es flaky; es una incompatibilidad real de versión o SO, que sí es de aquí.

La regla mecánica: si la pregunta es "¿en qué entornos corro mi suite?", es este módulo. "¿Cómo reproduzco lo que falló?" es el 3, "¿cómo lo hago rápido?" es el 5, "¿por qué es inconsistente?" es el 7.

Resumen y siguiente paso

En esta lección instalaste la idea que sostiene el módulo: un solo job verde afirma un solo entorno, y tu código vive en muchos. La estrategia de matriz es la cocina de cinco estufas: corre tu misma suite en varias versiones de Python y sistemas operativos, en paralelo, y te da un veredicto por combinación, convirtiendo "funciona en mi máquina" en una tabla honesta de dónde funciona y dónde no.

Lo viste latir con una demo local real: Reservo estrenó report_pages, una feature que usa itertools.batched en Python 3.12+ y un fallback manual antes, y dos tests espejo con @pytest.mark.skipif que se saltan en versiones opuestas. Corriste la suite en Python 3.14.0 y leíste 8 passed, 1 skipped, entendiendo que el skip no es un error sino un tercer estado —"este caso no aplica aquí, y lo dijimos a propósito"— y que en otra versión el que se salta sería el otro test. También quedó clara la honestidad de la guía: las corridas de pytest son reales, el YAML de la matriz es contenido que aprendes a leer y escribir.

Antes de avanzar deberías poder: explicar con tus palabras qué es una matriz de CI y por qué existe; decir qué afirma —y qué no— un solo job verde; predecir qué test se salta en 3.11 versus en 3.14 y por qué; y distinguir skipped de passed y de failed.

Lo que sigue, en la lección 2, es abrir en canal la pregunta de fondo: ¿qué cambia de verdad entre un entorno y otro? Vas a ver los tipos concretos de diferencia —features de la stdlib que aparecen en una versión, sintaxis nueva, dependencias que no compilan, separadores de ruta que difieren por sistema operativo— para entender no solo que la matriz es útil, sino contra qué peligros exactos te protege.

Recursos