Módulo 8: Proyecto — un pipeline de CI para Reservo

4. La matriz de versiones

Descripción

Tu pipeline ya corre la suite en cada push (lección 2), sobre un entorno reproducible (lección 3). Pero corre en un Python: el 3.14 de la máquina. Verde ahí significa "pasa en 3.14", nada más. Y Reservo, como librería que otros equipos instalan, promete soportar 3.11, 3.12 y 3.13 —tres promesas a usuarios que no controlas—. La capa que apilamos aquí, la matriz de versiones, convierte esas promesas en pruebas: envuelve el workflow base en una strategy.matrix que lo corre en las tres versiones a la vez, cada una en su propio job paralelo, cada una con su veredicto.

Es la capa del módulo 4, ahora tejida en el conjunto. Vas a ver cómo una lista de tres versiones se multiplica en tres jobs, cómo ${{ matrix.python-version }} inyecta la versión de cada celda en setup-python, y por qué fail-fast: false te da el mapa completo en vez de cancelar al primer rojo. Y vas a reencontrar la feature dependiente de versión de Reservo —report_pages, que usa itertools.batched en 3.12+ y un fallback manual antes— con sus dos tests espejo skipif, corriendo tu máquina como celda-testigo. La corrida es real: 13 passed, 1 skipped en 3.14, con el detalle fino de qué test se salta según la versión.

Al terminar vas a tener la matriz en su sitio dentro del pipeline y a saber justificar su tamaño: por qué tres versiones de Python y por qué —hoy— una sola fila de sistema operativo. Porque el capstone se evalúa por el método, y encender celdas por reflejo es lo contrario del método.

Conexión con el módulo: esta es la tercera capa, apilada sobre el piso (lección 2) y la reproducibilidad (lección 3). La matriz envuelve exactamente los steps que ya tienes: cada celda hace checkout, setup-python, install y pytest, igual que el piso, solo que parametrizada por su versión. Y es la capa que hace urgente la siguiente: correr la suite tres veces por push multiplica el tiempo, así que la lección 5 —caché y paralelismo— llega justo después, no por casualidad. La reproducibilidad de la lección 3 es lo que hace que cada celda sea un experimento limpio: la foto pinneada garantiza que "la celda de 3.11 falló" signifique "el bug es de 3.11", no "quizá esa celda instaló algo raro".

El cinturón que probaron en un solo maniquí

Piensa en una fábrica de cinturones de seguridad. Diseñan uno nuevo, lo prueban con un maniquí de choque —un adulto promedio, 1.75 m, 78 kg— y sale perfecto: sujeta, no se rompe, salva la vida simulada. Lo aprueban. Meses después llegan reportes: en accidentes reales, el cinturón lastima a personas pequeñas y no sujeta bien a las grandes. "Pero pasó la prueba", dicen. Sí —la prueba con un maniquí—.

El problema no es el cinturón; es que lo probaron contra un solo cuerpo. Un cinturón lo usan cuerpos de todos los tamaños: niños, adultos altos, personas corpulentas. Probarlo solo con el maniquí promedio afirma que funciona para ese cuerpo, y calla sobre todos los demás. La fábrica seria prueba el cinturón contra una batería de maniquíes —un niño, una mujer pequeña, un hombre grande, el promedio— antes de aprobarlo. Si sujeta bien a los cuatro, sale con confianza; si falla con el niño, lo descubre en el laboratorio, no en un accidente.

La matriz de versiones es esa batería de maniquíes. Tu código es el cinturón. Cada versión de Python es un cuerpo distinto que lo va a usar. Probar solo en 3.14 —tu maniquí promedio— afirma que Reservo funciona en 3.14 y calla sobre 3.11, 3.12, 3.13, que son las versiones que tus usuarios de verdad tienen. La matriz corre la suite contra los cuatro cuerpos a la vez, y te da un veredicto por cada uno: sujeta en 3.11, sujeta en 3.12, sujeta en 3.13, sujeta en 3.14. "Funciona en mi versión" deja de ser una esperanza y se vuelve una tabla.

La matriz prueba tu código contra cada versión que prometes soportar, como un cinturón contra una batería de maniquíes. Un solo entorno verde afirma un solo cuerpo; la matriz afirma —o desmiente— todos los que van a usarlo.

La matriz, tejida sobre el workflow base

Aquí está el pipeline base de la lección 2, ahora envuelto en una matriz de tres versiones. Fíjate en lo poco que cambia y en lo mucho que gana:

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false      # queremos ver las tres celdas, no cancelar al primer rojo
      matrix:
        python-version: ["3.11", "3.12", "3.13"]   # exactamente lo que promete el README
    steps:
      - name: Check out the code
        uses: actions/checkout@v5

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements-dev.txt

      - name: Run the test suite
        run: python -m pytest

Las tres piezas nuevas, y por qué cada una:

  • strategy.matrix.python-version: ["3.11", "3.12", "3.13"] — la lista de versiones, entre comillas (lección 2). Esta lista es la promesa del README hecha ejecutable: ni una versión de más, ni una de menos. Una lista de tres genera tres jobs, idénticos salvo la versión.
  • ${{ matrix.python-version }} en setup-python — la conexión que hace que cada celda instale su versión. Sin esta línea, las tres celdas correrían la misma versión y la matriz sería teatro. Con ella, la celda test (3.11) instala 3.11, la test (3.12) instala 3.12, y así.
  • fail-fast: false — el interruptor que, siendo Reservo una librería, quiero apagado: si una versión falla, quiero saber si las otras también, no que GitHub cancele las celdas hermanas al primer rojo. El mapa completo, no el primer disparo.

Todo lo demás —los cuatro steps— es el piso de la lección 2, sin cambios. Esa es la belleza de la matriz: no reescribe el trabajo, lo envuelve y lo multiplica. Escribes los steps una vez y la lista de versiones una vez; GitHub hace el producto por ti.

La feature que delata la versión, y sus tests espejo

Para que la matriz tenga algo que ver, Reservo incluye una feature que se comporta distinto según la versión: report_pages, que agrupa reservas en páginas para el reporte diario. Usa itertools.batched —una función de la librería estándar que apareció en Python 3.12— cuando está disponible, y un fallback manual cuando no:

# reservo/reports.py
import sys

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)]

El comportamiento visible es idéntico en ambas ramas; lo que cambia es el camino interno según la versión. Para probar las dos ramas —cada una en la versión donde vive— la suite usa dos tests con @pytest.mark.skipif de condiciones opuestas, más un test universal:

# tests/test_version_features.py
import sys

import pytest

from reservo.reports import report_pages


def test_report_pages_groups_bookings():
    # Regla que 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():
    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():
    assert "batched" not in dir(__import__("itertools"))

Los dos skipif son espejos: test_report_pages_uses_stdlib_batched se salta en 3.11 (donde batched no existe) y corre en 3.12+; test_report_pages_manual_fallback_on_old_python hace lo contrario. En cualquier versión, exactamente uno de los dos corre y el otro se salta. Entre las celdas de 3.11 y de 3.12+, ambas ramas quedan ejercitadas, cada una donde vive.

Ejemplo trabajado: tu máquina como celda-testigo

Corramos la suite completa en la máquina de la guía —Python 3.14.0— que hace de una celda de la matriz (3.14 es ≥ 3.12, así que ejercita la misma rama que 3.12 y 3.13):

python -m pytest -v -rs

Qué esperar (salida real en Python 3.14.0, con -rs para ver la razón del skip):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/reservo-m8/.venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/reservo-m8
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0, rerunfailures-16.4, cov-7.1.0
collected 14 items

tests/test_pricing.py::test_basic_three_hours PASSED                       [ 35%]
tests/test_pricing.py::test_pro_three_hours PASSED                         [ 42%]
tests/test_pricing.py::test_basic_one_hour PASSED                          [ 50%]
tests/test_version_features.py::test_report_pages_groups_bookings PASSED   [ 85%]
tests/test_version_features.py::test_report_pages_uses_stdlib_batched PASSED [ 92%]
tests/test_version_features.py::test_report_pages_manual_fallback_on_old_python SKIPPED [100%]

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

(Recorté las filas de precios y reembolsos para no repetir; el conteo final es de la corrida completa.) El resumen: 13 passed, 1 skipped. El test que se salta es test_report_pages_manual_fallback_on_old_python, porque en 3.14 (≥ 3.12) la rama del fallback no aplica, y su razón está impresa gracias a -rs. Un skip no es un fallo: es "este caso no aplica aquí, y lo dijimos a propósito", un tercer estado que no altera el exit code 0.

Cómo se leerían las tres celdas

Sin runner, así se vería la lista de jobs de la matriz, toda verde (formato honesto del log; el conteo por celda es el que la celda reportaría):

tests · push a main   (fail-fast: false)
  ✓ test (3.11)   —  13 passed, 1 skipped
  ✓ test (3.12)   —  13 passed, 1 skipped
  ✓ test (3.13)   —  13 passed, 1 skipped

Las tres dicen 13 passed, 1 skipped, pero —el detalle fino— el test que se salta es distinto en 3.11 que en 3.12/3.13:

CeldaRama que ejercitaTest que se salta
test (3.11)fallback manualtest_report_pages_uses_stdlib_batched
test (3.12)itertools.batchedtest_report_pages_manual_fallback_on_old_python
test (3.13)itertools.batchedtest_report_pages_manual_fallback_on_old_python

En 3.11 se ejercita el fallback (y se salta la rama de batched, que ahí no existe). En 3.12 y 3.13 se ejercita batched. Entre las tres celdas, ambas ramas quedaron probadas de verdad. Un solo job no podría darte eso: probaría una rama y dejaría la otra sin tocar. Y si una celda se pusiera roja —digamos test (3.11) con ImportError: cannot import name 'batched'—, el nombre te diría al instante que el bug es de 3.11 (alguien usó batched sin el fallback), y reproducirlo sería instalar 3.11 y correr la suite (la técnica del módulo 3, la reproducibilidad de la lección anterior).

Qué matriz merece Reservo

El capstone se evalúa por el método, así que la matriz no se elige por reflejo sino por lo que el proyecto de verdad arriesga. La regla: prueba lo que envías más lo que prometes soportar, y nada más.

  • Tres versiones de Python: sí pagan. Reservo es una librería que otros instalan, y su pyproject.toml/README prometen 3.11, 3.12 y 3.13. Cada una es una promesa a usuarios que no controlas. Además, Reservo usa una feature que varía por versión (report_pages), así que sin las tres celdas una rama quedaría sin ejercitar donde vive. La dimensión de versión no es reflejo: es la promesa hecha verificable.
  • La dimensión de sistema operativo: no paga (hoy). La lógica núcleo de Reservo —price_cents, refund_cents, overlaps, book— es aritmética de enteros y comparación de fechas: da idéntico en Linux, macOS y Windows. Una matriz 3×3 correría nueve celdas para obtener seis veces el mismo verde. Es costo (nueve corridas por push, con macOS y Windows más caros) y ruido, sin cazar un bug que la fila de Linux no cace ya.
  • El disparador para agregar SO. El día que Reservo escriba reportes a disco —rutas, encoding, saltos de línea, que difieren por sistema—, ahí sí entraría os: [ubuntu-latest, windows-latest] (POSIX vs. Windows, la diferencia real). Hasta entonces, agregarla sería adelantar un costo sin cobertura.

Conclusión: tres celdas (3.11, 3.12, 3.13 en Linux), con fail-fast: false. La matriz que corresponde a lo que Reservo de verdad arriesga como librería, no las nueve de una 3×3 por reflejo ni la única celda que dejaría dos versiones prometidas sin probar.

Errores comunes

Olvidar ${{ matrix.python-version }} en setup-python. Qué pasa: alguien escribe la lista ["3.11", "3.12", "3.13"] pero deja setup-python con python-version: "3.14" fijo. GitHub abre tres jobs —el log se ve "correcto", con tres celdas—, pero las tres instalan 3.14, así que la matriz es teatro: prueba la misma versión tres veces. Por qué pasa: la lista de la matriz y el step que la consume están separados en el YAML, y es fácil actualizar una y olvidar el otro. Cómo detectarlo: mira el encabezado de cada celda en el log; si las tres dicen la misma versión de Python, la conexión falta. Cómo corregirlo: la línea python-version: ${{ matrix.python-version }} es lo que hace que cada celda instale su versión. Sin ella, la matriz gira en vacío.

Cubrir solo una rama de la feature de versión. Qué pasa: se escribe el test de itertools.batched pero no el del fallback, así que en la celda de 3.11 la rama del fallback nunca se afirma —queda verde por omisión—. Por qué pasa: en tu máquina (3.12+) solo ves la rama de batched, y es fácil olvidar la otra. Cómo detectarlo: por cada if sys.version_info en el código, pregúntate "¿tengo un test para cada rama, con skipif que lo corra donde aplica?". Cómo corregirlo: los dos tests espejo, con condiciones opuestas, más al menos una versión de cada lado de la frontera (3.12) en la matriz.

Inflar la matriz a 3×3 "para estar seguro". Qué pasa: se agrega os: [ubuntu, macos, windows] subiendo a nueve celdas, aunque el código de Reservo no toca nada específico del sistema. Por qué pasa: más celdas se siente más robusto. Cómo detectarlo: por la dimensión de SO, pregúntate "¿qué bug caza que la fila de Linux no cazaría?"; para lógica pura, ninguno. Cómo corregirlo: una sola fila de sistema mientras el código sea lógica pura, documentado en un comentario; agrega la dimensión de SO solo cuando el código empiece a tocar el disco.

Ejercicios

Ejercicio 1 — Predice el skip en 3.11. En 3.14 la corrida dio 13 passed, 1 skipped, y el que se saltó fue test_report_pages_manual_fallback_on_old_python. Sin correr nada, predice: en la celda de 3.11, ¿cuántos pasan y cuántos se saltan, y cuál test se salta? Una frase de por qué.

Ver solución

Seguiría siendo 13 passed, 1 skipped, pero el test que se salta sería test_report_pages_uses_stdlib_batched. Razón: en 3.11, la condición de ese test, sys.version_info < (3, 12), es verdadera (3.11 < 3.12), así que se salta; y la 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 se salta. Lo que cambia entre celdas no es el conteo total, sino cuál camino de código quedó ejercitado —y por eso vale correr las dos versiones—.

Ejercicio 2 — La matriz hace urgente la lección 5. Explica, en dos o tres frases, por qué agregar la matriz de tres versiones hace que la siguiente capa —caché y paralelismo— pase de "lujo" a "necesidad".

Ver solución

La matriz multiplica el trabajo por tres: cada push ahora corre el pipeline completo tres veces —tres checkouts, tres instalaciones de dependencias, tres corridas de la suite—, una por versión. Cualquier lentitud que era tolerable en un solo job se paga por triplicado: si instalar dependencias tarda un minuto, ahora son tres minutos solo en instalar, repetidos en cada celda aunque las dependencias sean casi las mismas. Por eso el caché (no volver a bajar las dependencias en cada celda) y el paralelismo (correr los tests de cada celda en varios procesos) dejan de ser optimizaciones opcionales y se vuelven lo que mantiene el ciclo de feedback corto cuando hay matriz. La lección 5 llega justo después de esta no por casualidad: la matriz crea el problema de velocidad que la lección 5 resuelve.

Ejercicio 3 — Justifica (o rechaza) una cuarta celda. El equipo propone agregar macos-latest a la matriz "porque varios desarrolladores usan Mac". ¿Es defendible para Reservo tal como está? ¿Qué preguntarías antes de decidir?

Ver solución

Para Reservo tal como está —lógica pura de aritmética de enteros y fechas—, agregar macos-latest no es defendible por reflejo, porque el código da idéntico en macOS que en Linux: no toca rutas, ni archivos, ni saltos de línea, ni nada que difiera por sistema operativo. La celda de macOS correría la misma suite y daría el mismo verde que la de Linux, sin cazar un solo bug extra, y macOS es de los runners más caros en minutos facturados. Sería costo puro.

La pregunta clave antes de decidir es: "¿qué comportamiento de Reservo podría diferir en macOS que Linux no cubra ya?". Si la respuesta honesta es "ninguno" (el caso actual), la celda no paga. La razón "varios desarrolladores usan Mac" confunde dónde se desarrolla con dónde el código se comporta distinto: que el equipo use Mac para escribir no significa que el código de Reservo se ejecute distinto ahí. La celda de macOS se justificaría el día que Reservo empezara a hacer algo dependiente del sistema —escribir archivos, invocar comandos del SO, manejar rutas— y el equipo tuviera usuarios en macOS reportando bugs. Hasta entonces, la matriz correcta es la de versiones en una sola fila de Linux. La disciplina del capstone: cada celda se justifica por un riesgo real, no por una costumbre del equipo.

Resumen y siguiente paso

En esta lección apilaste la tercera capa: la matriz de versiones. Envolviste el workflow base en una strategy.matrix de ["3.11", "3.12", "3.13"], conectada a setup-python con ${{ matrix.python-version }} y con fail-fast: false para ver el mapa completo, entendiendo que la matriz no reescribe el trabajo sino que lo envuelve y lo multiplica. Reencontraste report_pages con sus dos tests espejo skipif, corriste tu máquina como celda-testigo —13 passed, 1 skipped en 3.14— y viste que entre las celdas de 3.11 y 3.12+ ambas ramas de la feature quedan probadas, cada una donde vive.

Aprendiste a leer las tres celdas —mismo conteo, distinto test saltado por versión— y a localizar un bug por el nombre de la celda roja. Y justificaste qué matriz merece Reservo: tres versiones (la promesa del README hecha verificable) en una sola fila de sistema (porque su lógica pura da idéntico en todo SO), ni las nueve de una 3×3 por reflejo ni la única celda que dejaría promesas sin probar. El método, no el reflejo.

Antes de avanzar deberías poder: escribir una strategy.matrix de versiones y conectarla a setup-python; explicar qué hace fail-fast: false; predecir qué test se salta en cada versión y por qué; y justificar el tamaño de una matriz por el riesgo real del proyecto.

Lo que sigue, en la lección 5, es la consecuencia directa de tener matriz: la velocidad. Correr la suite tres veces por push multiplica el tiempo, y si cada celda baja las dependencias de internet y corre los tests en serie, el ciclo de feedback se alarga justo cuando más lo usas. Vas a apilar la capa de caché (guardar las dependencias entre corridas) y paralelismo (pytest-xdist, -n auto), con una demo real de velocidad —y una honestidad importante sobre cuándo el paralelismo paga y cuándo, en una suite como la de Reservo, solo añade sobrecarga—.

Recursos