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

2. Por qué probar en varios entornos

Descripción

En la lección anterior instalamos la idea de la matriz con una analogía —la cocina de cinco estufas— y una demo. Pero una analogía no basta para diseñar bien: para saber qué matriz merece tu proyecto, primero tienes que saber contra qué peligros exactos te protege. Esta lección abre en canal la pregunta de fondo: ¿qué cambia de verdad entre un entorno y otro? Porque si nada cambiara, la matriz sería un desperdicio; correrías la misma suite en nueve entornos idénticos para obtener nueve veces el mismo verde. La matriz vale la pena precisamente porque los entornos no son idénticos, y las diferencias, aunque pequeñas, son reales y muerden.

Al terminar vas a poder nombrar las categorías concretas de diferencia entre entornos —features de la librería estándar que aparecen en una versión y no en la anterior, sintaxis nueva del lenguaje, ruedas de dependencias que compilan en un sistema operativo y no en otro, separadores de ruta y saltos de línea distintos por SO, encodings por defecto— y reconocer cuáles de esas diferencias tu código toca sin darse cuenta. Vas a ver con Reservo, ejecutando de verdad, cómo itertools.batched existe en tu Python 3.14 pero no existiría en 3.11: un test que pasa para ti se rompería para el usuario de 3.11, y tú nunca lo sabrías con un solo job. El skipif reaparece aquí no como un truco, sino como el bisturí exacto para aislar esa diferencia y probar cada rama donde corresponde.

Conexión con el módulo: la lección 1 te dio el qué (la matriz) y el para qué (funciona en mi máquina no basta). Esta te da el contra qué: el catálogo de diferencias que justifica encender más de una celda. Es la base conceptual de las cuatro lecciones que siguen. La lección 3 va a expresar la dimensión "versión de Python" en el YAML; esta lección te dice por qué esa dimensión importa. La lección 4 hará lo mismo con la dimensión "sistema operativo"; aquí verás por adelantado qué cambia entre sistemas. Y la lección 7, cuando decidas si la matriz paga, se apoyará en este catálogo: solo enciendes una celda si tu código toca alguna de estas diferencias en un entorno que te importa.

El mismo enchufe, distinto voltaje

Cuando viajas de México a Europa con tu cargador, te llevas un adaptador. El enchufe de la pared se ve casi igual —dos o tres agujeros—, pero por dentro corre otra cosa: 127 voltios en un lado del Atlántico, 230 en el otro. Si conectas un aparato de 127 voltios directo a una toma de 230 sin adaptador, no es que "casi funcione": se quema. Y lo cruel es que el aparato funcionó perfecto durante años en tu casa. "Funciona en mi enchufe" era verdad. Simplemente era una verdad sobre tu enchufe, no sobre todos los enchufes del mundo.

Los entornos de ejecución son enchufes. Python 3.11 y Python 3.13 se ven casi iguales —el mismo lenguaje, la misma sintaxis en un 99%—, pero por dentro tienen diferencias de voltaje: una función de la stdlib que existe en uno y no en el otro, una sintaxis que el nuevo acepta y el viejo rechaza con un error de sintaxis. Linux y Windows se ven casi iguales para tu código Python —abres archivos, unes rutas—, pero el separador de ruta es / en uno y \ en el otro, y el salto de línea es un carácter en uno y dos en el otro. Tu código, como el aparato, puede funcionar años en tu enchufe y quemarse la primera vez que lo conectan a otro.

La matriz es el cajón de adaptadores del viajero precavido: antes de mandar tu aparato a otro país, lo pruebas en el voltaje de ese país, en tu propia mesa, con un transformador. Si aguanta, lo mandas tranquilo. Si se calienta, lo descubres en casa. Este es el mapa de los "voltajes" que cambian entre entornos de Python — los que tu código toca sin darse cuenta.

Qué cambia de verdad entre versiones de Python

No todo cambia entre 3.11 y 3.13. La aritmética de enteros de Reservo —2500 * 3 == 7500— da lo mismo en todas las versiones; si dependiera solo de eso, no necesitarías una matriz de versiones. Lo que cambia, y muerde, cae en unas pocas categorías concretas.

Features de la librería estándar que aparecen en una versión. Esta es la más común y la que usamos en Reservo. itertools.batched no existía antes de Python 3.12; se agregó en 3.12. Si tu código hace from itertools import batched, funciona en 3.12+ y explota con un ImportError en 3.11. Lo mismo con tomllib (leer archivos TOML, agregado en 3.11), datetime.UTC (un alias corto, agregado en 3.11), typing.Self (agregado en 3.11), y decenas más. Cada versión de Python trae funciones nuevas, y usar una de ellas ata tu código, silenciosamente, a esa versión o superior.

Sintaxis nueva del lenguaje. A veces no es una función sino la gramática misma. La sintaxis de parámetros de tipo genéricos —def first[T](items: list[T]) -> T— llegó en Python 3.12. En 3.11, ese archivo ni siquiera compila: revienta con un SyntaxError al importarlo, antes de correr un solo test. Este tipo de diferencia es más brutal que la de una función faltante, porque no hay forma de esquivarla con un if: el intérprete viejo no puede ni leer el archivo.

Comportamiento que cambió sin cambiar de nombre. El caso más traicionero: una función que existe en las dos versiones pero se comporta distinto. Un ejemplo real: en Python 3.12, ciertos mensajes de error y repr cambiaron de formato; un test que hacía assert "foo" in str(error) podía pasar en una versión y fallar en otra porque el texto exacto del mensaje cambió. Aquí no hay ImportError ni SyntaxError que te avise: el test simplemente da rojo en una celda y verde en otra, y tienes que leer el diff para entender que el comportamiento se movió.

Dependencias que no tienen rueda para tu combinación. Tus dependencias (las que instalas con pip) a veces se distribuyen como ruedas (wheels) precompiladas para combinaciones específicas de versión de Python y sistema operativo. Si una librería no publicó una rueda para "Python 3.13 en Windows", pip intenta compilarla desde el código fuente, y eso puede fallar si falta un compilador. Reservo es stdlib pura y no sufre esto, pero es una de las razones más comunes por las que un pip install verde en tu máquina se pone rojo en una celda de la matriz.

Qué cambia de verdad entre sistemas operativos

La otra dimensión. Aquí las diferencias son más sutiles porque el mismo código Python corre en los tres sistemas —no hay ImportError—, pero el resultado difiere. Las mediré de verdad más abajo; por ahora, el catálogo.

El separador de ruta. En Linux y macOS, las rutas usan / (reservo/reports.py). En Windows, usan \ (reservo\reports.py). Si tu código construye rutas pegando strings con "/", funciona en tu Mac y se rompe en Windows. La forma correcta —os.path.join o pathlib.Path— usa el separador correcto según el sistema, pero mucho código no la usa.

El salto de línea. Al escribir un archivo de texto, Linux y macOS terminan cada línea con un solo carácter, \n. Windows usa dos, \r\n. Un test que hace assert contenido == "línea1\nlínea2\n" puede pasar en Linux y fallar en Windows si el archivo se escribió con los saltos de línea del sistema.

El encoding por defecto. Al abrir un archivo sin decir el encoding, Python usa el "por defecto del sistema", que históricamente era UTF-8 en Linux/macOS y algo distinto (como cp1252) en Windows. Un archivo con un acento —una ó en un nombre de sala— podía leerse bien en un sistema y salir con basura en otro. (Python 3.15 vuelve UTF-8 el default en todos lados, pero durante años esto fue una fuente clásica de rojos solo-en-Windows.)

La sensibilidad a mayúsculas. En Linux, Reservo.py y reservo.py son dos archivos distintos. En macOS y Windows, por defecto, son el mismo. Un import que funciona en tu Mac por accidente —escribiste from Reservo import x y el sistema no distinguió— truena en el Linux del runner.

La demo: itertools.batched en tu versión y en la que no

Bajemos esto a Reservo, ejecutando de verdad. Recuerda que en la lección 1 le agregamos report_pages, que usa itertools.batched en 3.12+ con un fallback manual antes. La razón por la que hicimos ese if sys.version_info >= (3, 12) no era capricho: era esquivar exactamente la primera categoría de diferencia. Veámoslo.

Primero, confirmemos en la máquina real —Python 3.14.0— que itertools.batched de verdad existe aquí:

python -c "print('batched' in dir(__import__('itertools')))"

Qué esperar. En Python 3.14.0:

True

batched está en itertools, porque 3.14 es ≥ 3.12. Ahora imagina el mismo comando en Python 3.11: imprimiría False, porque ahí la función no existe. Esa sola diferencia —True aquí, False allá— es la que hace o rompe un test.

Mira el test que la usa directo, sin protección:

def test_report_pages_uses_stdlib_batched():
    from itertools import batched
    assert list(batched("abcde", 2)) == [("a", "b"), ("c", "d"), ("e",)]

En tu Python 3.14, este test pasa: el import funciona y batched("abcde", 2) agrupa de a dos. Pero si el mismo test corriera en Python 3.11, la línea from itertools import batched fallaría antes de llegar al assert, con un error así:

ImportError: cannot import name 'batched' from 'itertools'

Y aquí está el peligro exacto que la matriz previene: en tu máquina, ese test es verde y estás feliz. No hay ninguna señal de que algo ande mal. Si tu pipeline corre un solo job en 3.14, verás verde y publicarás. El usuario que instale tu código en 3.11 será quien descubra el ImportError, en producción, en su cara —el platillo crudo en la estufa que nunca probaste—. Un solo job verde te mintió por omisión: era verdad sobre 3.14 y tú lo leíste como verdad sobre todo.

Ejemplo trabajado: el skipif como bisturí para la diferencia

¿Qué haces cuando un test solo tiene sentido en ciertas versiones? No lo borras —quieres probarlo donde aplica— y no lo dejas explotar donde no aplica. Lo marcas con @pytest.mark.skipif, que es precisamente el bisturí para aislar una diferencia de versión. Repasemos los dos tests espejo de Reservo, ahora con el ojo puesto en qué diferencia está aislando cada uno:

# tests/test_version_features.py (fragmento)
import sys
import pytest

@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"))

El primer skipif dice: "sáltate este test si la versión es menor que 3.12". Así, en 3.11 —donde from itertools import batched explotaría— el test ni siquiera intenta el import: se salta limpio, con su razón anotada. En 3.12+ corre y prueba de verdad la rama que usa la stdlib. El segundo skipif es el espejo: prueba la rama del fallback manual, y solo tiene sentido en 3.11, así que se salta en 3.12+.

Corramos solo este archivo en la máquina real para ver el bisturí en acción, con -rs para que imprima las razones de los skips:

python -m pytest -v -rs tests/test_version_features.py

Qué esperar. En Python 3.14.0, medido de verdad:

============================= 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
collecting ... collected 3 items

tests/test_version_features.py::test_report_pages_groups_bookings PASSED  [ 33%]
tests/test_version_features.py::test_report_pages_uses_stdlib_batched PASSED [ 66%]
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
========================= 2 passed, 1 skipped in 0.00s =========================

Lee la historia que cuenta este resultado. test_report_pages_groups_bookings —la regla que vale en toda versión— pasó. test_report_pages_uses_stdlib_batched —la rama de la stdlib— pasó, porque en 3.14 itertools.batched existe. Y test_report_pages_manual_fallback_on_old_python —la rama del fallback— se saltó, con su razón clarísima: "el fallback manual solo se ejercita en Python < 3.12". Ninguno explotó. El skipif cortó justo por donde había que cortar: probó lo que aplica, saltó lo que no, y dejó constancia escrita de la decisión.

Ahora ata el cabo con la matriz. Este mismo archivo, en la celda de 3.11, invertiría los skips: probaría el fallback (que ahí sí aplica) y saltaría la rama de batched (que ahí no existe). Con las dos celdas —3.11 y 3.12+— ambas ramas de report_pages quedan ejercitadas de verdad, cada una en la versión donde vive. Eso es lo que un solo job no puede darte: un solo job prueba una rama y deja la otra sin tocar. La matriz cubre las dos.

Un matiz importante: skipif no es la solución, es la honestidad

Cuidado con una lectura fácil: "ah, entonces pongo skipif en todo y ya, resuelto". No. El skipif no arregla la incompatibilidad; la reconoce y la ubica. Si tu código usa itertools.batched sin el fallback, skipif en el test no cambia que tu código se rompe en 3.11 —solo evita que el test finja un veredicto donde no puede opinar—. La incompatibilidad real la resuelves en el código (con el if sys.version_info que elige la rama, o subiendo tu versión mínima soportada), no en el test.

Entonces, ¿para qué sirve el skipif? Para dos cosas honestas. Primera: probar una rama que solo existe en ciertas versiones, sin que el test explote en las demás (nuestro caso). Segunda: documentar, de forma ejecutable, que "este comportamiento solo aplica aquí". Un skipif con una reason clara es un comentario que el runner respeta. Lo que no debe ser es una alfombra bajo la que barres una incompatibilidad para que el CI se calle: si te descubres poniendo skipif para que un test deje de fallar en una versión que prometes soportar, no estás probando —estás escondiendo el problema.

Errores comunes

Asumir que "pasa en mi versión" es "pasa en todas". Qué pasa: escribes un test que usa una función nueva de la stdlib, pasa en tu 3.14, y publicas. En 3.11 truena con ImportError y lo descubre un usuario. Por qué pasa: tu máquina tiene una sola versión, y esa versión te da una sola respuesta; no ves las otras. Cómo detectarlo: cada vez que uses una función de la stdlib, pregúntate "¿desde qué versión existe esto?" —la doc lo dice con un "Added in version X"—. Cómo corregirlo: o subes tu versión mínima soportada a esa, o agregas un fallback, o —para saberlo antes de publicar— corres una matriz que incluya tu versión mínima.

Pegar rutas con strings y probar solo en tu SO. Qué pasa: construyes una ruta con carpeta + "/" + archivo, funciona en tu Mac, y en el Windows de un compañero el separador \ la rompe. Por qué pasa: tu SO usa /, así que nunca ves el problema; el / a mano es cómodo y silenciosamente incorrecto. Cómo detectarlo: busca en tu código concatenaciones de ruta con "/" o "\\"; cada una es un candidato a romperse en otro SO. Cómo corregirlo: usa os.path.join o pathlib.Path, que ponen el separador correcto por sistema, y —si el código toca el sistema de archivos de verdad— incluye Windows en tu matriz para comprobarlo.

Usar skipif para silenciar en vez de para ubicar. Qué pasa: un test falla en 3.11, y en lugar de arreglar la incompatibilidad, alguien le pone @pytest.mark.skipif(sys.version_info < (3, 12), ...) para que el CI se ponga verde. Pero el proyecto promete soportar 3.11. Ahora el CI miente: dice verde, pero el código está roto en una versión que prometiste. Por qué pasa: es más rápido callar un test que arreglar el código. Cómo detectarlo: por cada skipif, pregúntate "¿el código de verdad no aplica aquí, o solo estoy tapando que se rompe?". Cómo corregirlo: si prometes soportar la versión, arregla el código (fallback o versión mínima); reserva skipif para casos donde la rama de código genuinamente no existe en esa versión.

Ejercicios

Ejercicio 1 — Clasifica la diferencia. Para cada situación, di a qué categoría de diferencia entre entornos pertenece —(i) función de la stdlib que aparece en una versión, (ii) sintaxis nueva del lenguaje, (iii) diferencia de sistema operativo— y si se manifestaría como ImportError, SyntaxError, o un test que da rojo sin error de import: (a) usar from tomllib import load (agregado en 3.11) en Python 3.10; (b) escribir def wrap[T](x: T) (sintaxis de 3.12) y correrlo en 3.11; (c) un test que hace assert path == "data/out.txt" corriendo en Windows.

Ver solución
  • (a) Categoría (i), función de la stdlib. tomllib se agregó en 3.11; en 3.10 no existe. Se manifiesta como ImportError en la línea from tomllib import load, antes de correr nada. Se esquiva con un fallback (el paquete externo tomli) o subiendo la versión mínima a 3.11.
  • (b) Categoría (ii), sintaxis nueva. Los parámetros de tipo [T] en la firma llegaron en 3.12. En 3.11 el archivo no compila: SyntaxError al importarlo. Es la más dura, porque no hay if que la esquive: el intérprete viejo no puede ni leer el archivo. La única salida es no usar esa sintaxis si soportas 3.11.
  • (c) Categoría (iii), diferencia de SO. En Windows el separador es \, así que la ruta real sería data\out.txt, no data/out.txt. No hay error de import: el test simplemente da rojo porque el string esperado no coincide. Se arregla construyendo la ruta con os.path.join/pathlib y comparando de forma independiente del separador.

La utilidad de clasificar: (i) y (ii) las ves incluyéndolas en la matriz de versiones; (iii) la ves incluyéndolas en la matriz de sistemas operativos. Saber la categoría te dice qué dimensión de la matriz encender.

Ejercicio 2 — ¿Verde honesto o verde mentiroso? Tu proyecto promete en su README "soporta Python 3.11+". Un test usa itertools.batched directo. Un compañero, para que el CI deje de fallar en la celda de 3.11, le pone @pytest.mark.skipif(sys.version_info < (3, 12), reason="batched no está en 3.11"). El CI se pone verde. ¿Es un verde honesto? Explica qué está mal y cuál sería el arreglo correcto.

Ver solución

Es un verde mentiroso. El README promete soportar 3.11, pero el código usa itertools.batched, que en 3.11 no existe: el código está roto en una versión que prometiste soportar. El skipif no arregla eso; solo hace que el test deje de reportarlo, así que el CI dice "verde" mientras un usuario de 3.11 recibe un ImportError. Se barrió el problema bajo la alfombra.

Los arreglos correctos, en orden de preferencia:

  1. Arreglar el código, no el test. Poner el fallback como en Reservo: if sys.version_info >= (3, 12): from itertools import batched … else: comprensión manual. Así el código de verdad funciona en 3.11, y el test puede correr (con los dos skipif espejo probando cada rama donde aplica).
  2. Subir la versión mínima. Si de verdad quieres usar batched sin fallback, cambia el README y la config a "soporta 3.12+" y quita 3.11 de la matriz. Es honesto: ya no prometes algo que no cumples.

Lo que no vale es dejar el skipif silenciando el fallo mientras la promesa de soporte sigue diciendo 3.11. Regla: skipif documenta ramas que genuinamente no aplican; no oculta código roto en versiones que prometes.

Ejercicio 3 — Diseña la matriz mínima para una diferencia. Reservo estrena una función que lee su configuración de precios desde un archivo TOML con tomllib (agregado en Python 3.11), con un fallback al paquete externo tomli para versiones anteriores. El equipo promete soportar Python 3.10, 3.11 y 3.12. ¿Qué versiones como mínimo debería incluir la matriz para probar de verdad ambas ramas del código, y por qué esas?

Ver solución

Como mínimo, la matriz debe incluir una versión < 3.11 y una versión ≥ 3.11, porque ahí está la frontera del comportamiento. En concreto, dado que se prometen 3.10, 3.11 y 3.12:

  • 3.10 ejercita la rama del fallback (usa el paquete externo tomli, porque tomllib no existe aún). Sin una celda < 3.11, esa rama nunca se probaría de verdad.
  • 3.11 ejercita la rama de tomllib (la stdlib), y además es exactamente la versión-frontera donde el comportamiento cambia: el mejor lugar para cazar un error de borde.
  • 3.12 confirma que la rama de tomllib sigue bien en la versión más nueva soportada.

El principio: para una diferencia que ocurre en una frontera de versión, la matriz debe tener al menos una celda a cada lado de esa frontera, más las demás versiones que prometes soportar. Probar solo 3.12 dejaría la rama del fallback (la de 3.10) completamente sin ejercitar —verde por omisión, roto en silencio—. Este es exactamente el razonamiento que la lección 7 formaliza: prueba lo que prometes soportar, con foco en las fronteras donde el comportamiento cambia.

Resumen y siguiente paso

En esta lección abriste la caja de "qué cambia entre entornos", que es lo que justifica encender más de una celda de la matriz. Entre versiones de Python: funciones de la stdlib que aparecen en una versión (itertools.batched desde 3.12), sintaxis nueva que ni compila en la versión vieja, comportamiento que cambió sin cambiar de nombre, y dependencias sin rueda para tu combinación. Entre sistemas operativos: el separador de ruta (/ vs \), el salto de línea (\n vs \r\n), el encoding por defecto y la sensibilidad a mayúsculas. Cada una es un "voltaje" distinto que tu código puede tocar sin darse cuenta.

Lo viste ejecutando: itertools.batched existe en tu Python 3.14 (True) y no en 3.11; un test que lo usa directo pasa para ti y se rompería con ImportError para el usuario de 3.11 —el peligro exacto que un solo job verde no ve—. Y viste el skipif como el bisturí honesto: aísla la diferencia, prueba cada rama donde aplica (2 passed, 1 skipped), y deja constancia con su reason, sin fingir un veredicto donde no puede opinar. Quedó claro el matiz: skipif ubica la diferencia, no la arregla; el arreglo real vive en el código.

Antes de avanzar deberías poder: nombrar al menos tres categorías de diferencia entre entornos y decir cómo se manifiesta cada una (ImportError, SyntaxError, rojo sin error); explicar por qué un solo job verde puede mentir por omisión; y distinguir un skipif que documenta una rama legítima de uno que silencia código roto.

Lo que sigue, en la lección 3, es escribir la primera dimensión de la matriz en el YAML: strategy.matrix con varias versiones de Python. Vas a ver cómo una lista de tres versiones se convierte en tres jobs, cómo se inyecta la versión en setup-python, y cómo se vería el log de CI — con la corrida local en 3.14 como la celda-testigo que ejecutas de verdad.

Recursos