Módulo 4: La matriz — múltiples versiones y entornos
8. Mini-proyecto: una matriz de 3 versiones para Reservo
Descripción
Llegó el momento de juntar todo el módulo en una entrega. En las siete lecciones anteriores aprendiste qué es una matriz, contra qué peligros te protege, cómo escribir la dimensión de versión y la de sistema, cómo esculpirla con include/exclude/fail-fast, cómo leer sus N resultados, y cuándo paga. Ahora lo aplicas de principio a fin: configuras una matriz de tres versiones de Python para la suite de Reservo, con su feature dependiente de versión, y entregas la evidencia de que la entiendes.
Este no es un ejercicio suelto: es el trabajo real que harías al montar el CI multi-versión de un proyecto. Vas a producir cinco entregables concretos —el workflow YAML con strategy.matrix, la feature con @pytest.mark.skipif que se comporta distinto por versión, la corrida local que muestra 8 passed, 1 skipped con la evidencia de qué celda salta qué test, la lectura de los tres resultados que la matriz produciría, y una nota que justifica qué matriz merece Reservo de verdad—. Todo con la honestidad de la guía: el YAML es contenido (no hay runner aquí), pero la corrida de pytest es real, ejecutada en Python 3.14.0.
Conexión con el módulo: esta lección cierra el arco. La 1 te dio el concepto y la demo; la 2, el catálogo de diferencias; la 3, la dimensión de versión; la 4, la de sistema; la 5, el esculpido; la 6, la lectura; la 7, el juicio. El mini-proyecto los ejerce todos a la vez sobre Reservo. Y mira hacia adelante: al terminar tendrás una matriz que corre tu suite N veces por push, lo que hace urgente la pregunta del módulo 5 —¿cómo la hago rápida?— con caché y paralelismo. Cierras la matriz; el módulo 5 la acelera.
El encargo
Eres responsable del CI de Reservo como librería —la publicas para que otros equipos la instalen—. Tu pyproject.toml declara requires-python = ">=3.11" y prometes soportar Python 3.11, 3.12 y 3.13. Reservo incluye report_pages, que usa itertools.batched (stdlib desde 3.12) con un fallback manual para versiones anteriores. Tu trabajo:
- Escribir el workflow de GitHub Actions que corre la suite en las tres versiones prometidas.
- Asegurar que la feature dependiente de versión esté probada en ambas ramas, cada una donde aplica, con
skipif. - Correr la suite en local (tu celda-testigo) y capturar la evidencia.
- Describir cómo se leerían los tres resultados de la matriz.
- Justificar, con el criterio de la lección 7, por qué esta matriz —y no una más grande ni más chica— es la correcta para Reservo-como-librería.
Intenta cada paso por tu cuenta antes de mirar la solución. La solución completa está al final, pero el aprendizaje está en construirla tú.
Paso 1 — El workflow con strategy.matrix
Escribe .github/workflows/tests.yml. Debe correr en cada push y PR, instalar cada versión de la matriz con setup-python, instalar dependencias, y correr pytest. Recuerda: versiones entre comillas, y conectar ${{ matrix.python-version }} al paso de setup-python.
Piénsalo antes de seguir: ¿cuántos jobs genera tu matriz? ¿Qué línea hace que cada celda instale una versión distinta?
Paso 2 — La feature con skipif en ambas ramas
Reservo ya tiene report_pages con su if sys.version_info >= (3, 12). Tu suite debe probar las dos ramas: la que usa itertools.batched (aplica en 3.12+) y la del fallback manual (aplica en < 3.12). Como ninguna celda puede probar las dos a la vez —cada versión solo ejecuta una rama—, usas dos tests espejo con skipif de condiciones opuestas, más un test que valga en toda versión.
Piénsalo: ¿qué condición de skipif salta la rama de batched en 3.11? ¿Cuál salta el fallback en 3.12+?
Paso 3 — La corrida local (tu celda-testigo)
Corre la suite completa en tu máquina y captura la salida. Tu Python 3.14 hace de una de las celdas de la matriz. Usa -rs para que se vean las razones de los skips —esa evidencia es parte de la entrega—.
Paso 4 — Leer los tres resultados
Sin runner, describe cómo se verían las tres celdas de la matriz en el log de CI: sus nombres, su conteo, y —el detalle fino del módulo— qué test se salta en cada versión.
Paso 5 — La nota de decisión
Justifica el tamaño de la matriz. ¿Por qué tres versiones y no una? ¿Por qué (o por qué no) la dimensión de sistema operativo? Aplica la regla "envías + prometes, nada más".
Solución completa
Entregable 1 — El workflow YAML
# .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:
- 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.txt
- name: Run the test suite
run: python -m pytest -v
Las decisiones y su porqué:
python-version: ["3.11", "3.12", "3.13"], entre comillas, genera tres jobs. La lista es la promesa del README: ni una versión de más, ni una de menos. (Módulos 3 y 7.)${{ matrix.python-version }}ensetup-pythonconecta la matriz al paso que instala Python; sin esa línea, las tres celdas correrían la misma versión. (Lección 3.)fail-fast: falseporque, siendo una librería, quiero el mapa completo de resultados: si una versión falla, quiero saber si las otras también, no que se cancelen al primer rojo. (Lección 5.)- Una sola fila de sistema (
runs-on: ubuntu-latest, sin dimensiónos), decisión que justifico en la nota del entregable 5.
El requirements.txt que el workflow instala, para Reservo:
# requirements.txt
pytest==9.1.1
Reservo es stdlib pura, así que la única dependencia es pytest para correr los tests. Pinnearla (==9.1.1) es la lección del módulo 3: instalaciones deterministas para que la celda corra lo mismo que tú.
Entregable 2 — La feature y sus tests con skipif
El código de la feature (reservo/reports.py), que ya elige la rama por versión:
# 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)]
Los tests (tests/test_version_features.py), con las dos ramas cubiertas:
# 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"))
La clave: los dos skipif son espejos. test_report_pages_uses_stdlib_batched prueba la rama de batched y se salta en 3.11 (donde batched no existe). test_report_pages_manual_fallback_on_old_python prueba la rama del fallback y se salta en 3.12+. Entre las celdas de 3.11 y 3.12+, ambas ramas de report_pages quedan ejercitadas de verdad, cada una donde vive. El tercer test, sin skipif, verifica el comportamiento visible que debe ser idéntico en toda versión —la red de seguridad que atrapa si alguna rama se desvía del contrato—.
Entregable 3 — La corrida local, ejecutada de verdad
python -m pytest -v -rs tests/
Qué esperar. En Python 3.14.0 con pytest 9.1.1, medido ejecutando (esta es la salida real, no una maqueta):
============================= 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%]
=========================== 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 =========================
La evidencia clave, señalada:
platform darwin -- Python 3.14.0— la celda-testigo. Corre 3.14, que ejercita la misma rama que las celdas de 3.12 y 3.13 (todas ≥ 3.12).8 passed, 1 skipped— ocho pasan, uno se salta. Los ocho verdes incluyen los tres números-ancla de precio (7500, 6000, 2500) y los tres de reembolso (6000, 3000, 0), más el test universal dereport_pagesy la rama debatched.- El
SKIPPEDestest_report_pages_manual_fallback_on_old_python, y su razón —el fallback manual solo se ejercita en Python < 3.12— está impresa gracias a-rs. En 3.14 (≥ 3.12), esa rama no aplica, así que el test se salta limpio: ni pasa fingiendo, ni falla por algo irrelevante.
Los números-ancla, verificados en esta corrida (parte de la entrega, porque confirman que la suite prueba lo correcto):
| Test | Regla | Resultado |
|---|---|---|
test_basic_three_hours | basic, 3 h Focus | 7500 |
test_pro_three_hours | pro, 3 h (−20%) | 6000 |
test_basic_one_hour | basic, 1 h | 2500 |
test_full_refund_72h_before | cancela 72 h antes (≥48 h) | 6000 |
test_half_refund_36h_before | cancela 36 h antes (24–48 h) | 3000 |
test_no_refund_12h_before | cancela 12 h antes (<24 h) | 0 |
Entregable 4 — Cómo se leerían los tres resultados de la matriz
En el CI, la matriz produciría tres celdas. Así se vería la lista de jobs, 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) — 8 passed, 1 skipped
✓ test (3.12) — 8 passed, 1 skipped
✓ test (3.13) — 8 passed, 1 skipped
Las tres dicen 8 passed, 1 skipped, pero —y esto es lo que hay que entender— el test que se salta es distinto en 3.11 que en 3.12/3.13:
| Celda | Rama que ejercita | Test que corre | Test que se salta |
|---|---|---|---|
test (3.11) | fallback manual | test_report_pages_manual_fallback_on_old_python | test_report_pages_uses_stdlib_batched |
test (3.12) | itertools.batched | test_report_pages_uses_stdlib_batched | test_report_pages_manual_fallback_on_old_python |
test (3.13) | itertools.batched | test_report_pages_uses_stdlib_batched | test_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 (y se salta el fallback). Entre las tres celdas, las dos ramas de report_pages quedaron probadas de verdad, cada una en la versión donde vive. Eso es lo que un solo job no podría darte: probaría una rama y dejaría la otra sin tocar.
Y si una celda se pusiera roja —digamos test (3.11) con un ImportError: cannot import name 'batched'—, el patrón (una celda de versión) y el nombre (test (3.11)) me dirían al instante que el bug es de 3.11: alguien usó batched sin el fallback. El arreglo sería restaurar el fallback; la reproducción local, instalar 3.11 y correr la suite (módulo 3).
Entregable 5 — La nota de decisión: ¿qué matriz merece Reservo?
Reservo-como-librería merece la matriz de tres versiones de Python en una sola fila de sistema. El razonamiento, con la regla "envías + prometes, 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 controlo. Además, Reservo usa una feature que varía por versión (itertools.batched, con dos ramas de código): sin las tres celdas, una de las ramas quedaría sin ejercitar en la versión donde vive. La dimensión de versión no es reflejo, es exactamente la promesa hecha verificable. - La dimensión de sistema operativo: no paga (por ahora). La lógica núcleo de Reservo —
price_cents,refund_cents,overlaps,book— es aritmética de enteros y comparaciones de fechas: da idéntico en Linux, macOS y Windows. Yreport_pages, aunque varía por versión, no toca rutas ni archivos ni saltos de línea. No hay nada en el código actual que se comporte distinto por sistema, así que una matriz 3×3 correría nueve celdas para obtener seis veces el mismo verde. Sería el "seguro de flota para una bici" de la lección 7: costo (nueve corridas por push, con macOS y Windows más caros) y ruido, sin cazar un solo bug que la fila de Linux no cace ya. - El disparador para agregar la dimensión de SO. El día que Reservo escriba reportes a disco —rutas, encoding, saltos de línea—, ahí sí entraría
os: [ubuntu-latest, windows-latest](POSIX vs. Windows, la diferencia real; macOS es casi redundante con Linux para esto). 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 para ver el mapa completo. Es la matriz que corresponde a lo que Reservo de verdad arriesga como librería: las versiones que promete, en el único eje donde su código hoy varía. Ni las nueve celdas de una 3×3 por reflejo, ni una sola celda que dejaría dos versiones prometidas sin probar.
Errores comunes
Entregar el YAML sin la evidencia de la corrida local. Qué pasa: alguien escribe un strategy.matrix correcto pero no corre la suite ni una vez, así que no sabe si de verdad pasa ni qué se salta. Por qué pasa: el YAML "se ve bien" y da la sensación de trabajo terminado. Cómo detectarlo: si no tienes una salida de pytest con 8 passed, 1 skipped (o el conteo que sea), no has verificado nada, solo escrito intenciones. Cómo corregirlo: corre la suite en tu celda-testigo y captura la salida; esa evidencia es la mitad de la entrega, porque el YAML es contenido y la corrida es lo real.
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 3.11 la rama del fallback nunca se prueba —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, para que entre las celdas de la matriz ambas ramas queden ejercitadas.
Inflar la matriz de Reservo a 3×3 "para estar seguro". Qué pasa: se agrega os: [ubuntu, macos, windows] a la matriz de versiones, 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: pregúntate por la dimensión de SO "¿qué bug caza que la fila de Linux no cazaría?"; para lógica pura, la respuesta es ninguno. Cómo corregirlo: mantén una sola fila de sistema mientras el código sea lógica pura, y documenta en un comentario por qué; agrega la dimensión de SO solo cuando el código empiece a tocar el disco. (Lección 7.)
Ejercicios
Ejercicio 1 — Agrega una versión a la promesa. Reservo decide soportar también Python 3.14. Modifica la sección strategy.matrix del workflow para reflejar la nueva promesa, y di cuántos jobs genera ahora y qué test se saltaría en la nueva celda.
Ver solución
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
Ahora la lista tiene cuatro versiones, así que genera cuatro jobs: test (3.11), test (3.12), test (3.13), test (3.14). La nueva celda, test (3.14), es ≥ 3.12, así que ejercita la rama de itertools.batched —igual que 3.12 y 3.13— y se salta test_report_pages_manual_fallback_on_old_python (el fallback, que solo aplica en < 3.12). Su conteo sería 8 passed, 1 skipped, con el mismo test saltado que las otras celdas de 3.12+. Esta es exactamente la corrida que hiciste en local (tu máquina es 3.14), así que ya tienes su evidencia: platform darwin -- Python 3.14.0 ... 8 passed, 1 skipped. Agregar una versión a la lista sumó una celda (no multiplicó, porque hay una sola dimensión), y la promesa del README debería actualizarse a "3.11–3.14" para que matriz y promesa coincidan.
Ejercicio 2 — La librería sí necesita la dimensión de SO. Imagina que Reservo-librería agrega una función export_report(path) que escribe el reporte a un archivo de texto, con saltos de línea. Ahora justifica: ¿la matriz debe crecer? ¿A qué? Escribe el nuevo strategy.matrix y di cuántas celdas resultan.
Ver solución
Sí, la matriz debe crecer, porque ahora Reservo toca el terreno del sistema: escribir un archivo de texto involucra el separador de ruta (para path) y los saltos de línea (\n vs \r\n), que difieren por sistema operativo (lección 4). Una librería que promete Linux/macOS/Windows debe verificar que export_report funciona en los tres. El nuevo strategy.matrix:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest] # POSIX vs. Windows: la diferencia real
python-version: ["3.11", "3.12", "3.13"]
Resultan 2 × 3 = 6 celdas. Nota la decisión de la lección 7: incluí ubuntu y windows pero no macos, porque para las diferencias que importan aquí —separador y saltos de línea— macOS se comporta como Linux (ambos POSIX, / y \n), así que la celda de macOS sería casi redundante con la de Linux. Dos sistemas cubren la diferencia real (POSIX vs. Windows) por seis celdas, en vez de tres sistemas por nueve. Si el equipo quisiera ser exhaustivo o tuviera usuarios de macOS que reportan bugs, agregar macos-latest (nueve celdas) sería defendible; para empezar, seis cubren el riesgo real con menos costo. La regla "prueba lo que arriesgas" incluye no pagar por la celda casi redundante.
Ejercicio 3 — Detecta la rama sin probar. Un compañero entrega esta suite para la feature de versión. Corre 8 passed, 1 skipped en su máquina (3.13) y en la tuya (3.14). ¿Qué rama de report_pages queda sin probar de verdad en toda la matriz de 3.11/3.12/3.13, y cómo lo arreglas?
def test_report_pages_groups_bookings():
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 solo desde 3.12",
)
def test_report_pages_uses_stdlib_batched():
from itertools import batched
assert list(batched("abcde", 2)) == [("a", "b"), ("c", "d"), ("e",)]
Ver solución
Falta el test de la rama del fallback manual (la de else, que corre en Python < 3.12). Esta suite tiene el test universal (test_report_pages_groups_bookings) y el de la rama de batched (con skipif que lo salta en 3.11), pero no tiene el test espejo que verifica específicamente que en 3.11 se usa el fallback. Resultado: en la celda de 3.11, test_report_pages_uses_stdlib_batched se salta, y no queda ningún test que confirme que el fallback se ejercitó —solo el test universal, que pasa por la rama del fallback pero no afirma que sea el fallback—. La rama del else está probada solo de refilón.
Por qué es fácil no notarlo: en las máquinas de ambos (3.13 y 3.14, ≥ 3.12), la rama del fallback nunca se ejecuta, así que la falta no se ve en local; el 8 passed, 1 skipped se ve idéntico. Solo la celda de 3.11 de la matriz ejercitaría el fallback, y sin un test dedicado, nadie lo confirma.
El arreglo es agregar el test espejo con la condición opuesta:
@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"))
Ahora la celda de 3.11 corre este test (confirmando que ahí batched de verdad no existe y se usa el fallback) y salta el de batched; las celdas de 3.12+ hacen lo inverso. Con los dos espejos, ambas ramas quedan probadas, cada una en la versión donde vive. La lección: por cada if sys.version_info en el código, necesitas un test por rama, con skipif que lo corra donde aplica —y la matriz que incluya al menos una versión de cada lado de la frontera—.
Resumen y siguiente paso
En este mini-proyecto configuraste, de principio a fin, una matriz de tres versiones de Python para la suite de Reservo, y entregaste la evidencia de que la entiendes: el workflow YAML con strategy.matrix (tres versiones entre comillas, ${{ matrix.python-version }} conectado, fail-fast: false para ver el mapa completo), la feature con skipif en dos tests espejo que prueban ambas ramas de report_pages cada una donde vive, la corrida local real —8 passed, 1 skipped en Python 3.14.0, con la razón del skip impresa—, la lectura de los tres resultados con el detalle de qué test se salta por versión, y la nota de decisión que justifica por qué tres versiones y una sola fila de sistema es la matriz que Reservo-como-librería de verdad merece.
Esto cierra el módulo. Ahora sabes por qué un solo entorno verde no basta, cómo escribir la dimensión de versión y la de sistema, cómo esculpir la cuadrícula con include/exclude/fail-fast, cómo leer los N resultados para localizar un bug, y —lo que separa a quien copia una matriz de quien la diseña— cuándo la matriz paga y cuándo es ruido. La matriz convirtió "funciona en mi máquina" en una tabla honesta de dónde funciona.
Lo que sigue, en el módulo 5, es la consecuencia directa de tener una matriz: la velocidad. Una matriz corre tu suite N veces por push, y si cada celda instala dependencias desde cero y corre los tests en serie, el ciclo de feedback se alarga justo cuando más lo usas. El módulo 5 lo ataca con caché de dependencias y paralelismo (pytest-xdist), ejecutado de verdad en local —para que la matriz que acabas de construir no se vuelva un cuello de botella—.
Recursos
- Using a matrix for your jobs — GitHub Actions — la referencia completa que ejerciste en este proyecto: matriz de una dimensión,
include/exclude,fail-fast. Vuelve a ella cuando armes la matriz de tu propio proyecto. actions/setup-python: matrix testing — el patrón oficial desetup-pythondentro de una matriz de versiones, idéntico al del workflow que escribiste. Su README documenta los formatos de versión aceptados.pytest.mark.skipifyreason— documentación de pytest — la referencia de losskipifespejo que cubren las dos ramas de la feature. Presta atención a por qué elreason(que-rsimprime) vuelve auditable cada skip.itertools.batched— documentación de Python — la feature de la stdlib, con su "Added in version 3.12" que es la razón de toda la demo de versión. El próximo módulo (velocidad) parte de la matriz que este proyecto dejó armada.