Módulo 4: La matriz — múltiples versiones y entornos
4. La matriz de sistemas operativos
Descripción
La lección anterior te dio una dimensión: varias versiones de Python. Esta te da la segunda: varios sistemas operativos. Y con ella llega la idea que hace de la matriz algo más que una lista: cuando declaras dos dimensiones, GitHub no las suma, las multiplica. Tres versiones y tres sistemas operativos no son seis jobs, son nueve —el producto cartesiano, cada versión probada en cada sistema—. Entender esa multiplicación es entender por qué una matriz puede volverse enorme muy rápido, y por qué la lección 7 (cuándo paga) existe.
Al terminar vas a poder escribir os: [ubuntu-latest, macos-latest, windows-latest] junto a la lista de versiones, entender que runs-on: ${{ matrix.os }} manda cada celda a su sistema, y —lo más importante— saber qué diferencias reales entre sistemas operativos justifican encender esta segunda dimensión. Vas a ver, con valores medidos de verdad en macOS, cómo cambian el separador de ruta, el salto de línea, el nombre del sistema y el encoding, y vas a correr un test de Reservo diseñado para sobrevivir a esas diferencias —y entender por qué uno escrito con menos cuidado se rompería solo en Windows—.
Conexión con el módulo: la lección 3 construyó la dimensión de versión; esta construye la de sistema operativo y, al juntarlas, te enseña la multiplicación que gobierna el tamaño de toda matriz. La lección 5 usará esta cuadrícula 3×3 como el lienzo sobre el que include/exclude recortan y agregan celdas. La lección 6 leerá los nueve resultados que esta matriz produce. Y la lección 7 mirará este 3×3 = 9 y preguntará "¿de verdad necesitas las nueve?". Así que fija aquí dos cosas: la sintaxis de la segunda dimensión, y el catálogo concreto de lo que cambia entre sistemas.
El plano que se ve igual en dos obras distintas
Un arquitecto entrega el mismo plano a dos equipos de construcción, uno en la costa y otro en la montaña. El plano es idéntico: mismas medidas, mismas puertas. Pero la casa de la costa se construye sobre arena y la de la montaña sobre roca; en la costa hay que sellar contra la humedad salina y en la montaña hay que aislar contra el frío. El mismo plano, ejecutado en dos terrenos distintos, produce dos casas que —si el arquitecto no previó el terreno— pueden tener problemas opuestos: una con filtraciones, otra con grietas por el hielo.
Tu código Python es el plano. El sistema operativo es el terreno. El plano se ve igual —el mismo open(), el mismo os.path.join, la misma lógica de Reservo—, pero el terreno debajo cambia: en Linux el separador de ruta es /, en Windows es \; en Linux una línea de texto termina en un carácter, en Windows en dos. Un plano bien hecho tiene en cuenta el terreno (usa os.path.join en vez de pegar / a mano) y se construye bien en los tres. Un plano descuidado asume su propio terreno y se agrieta en el otro.
La matriz de sistemas operativos es mandar el plano a los tres terrenos antes de aprobarlo: construir la casa en arena, en roca y en la ciudad, y verificar que las tres queden en pie. Si una se agrieta, lo descubres en la maqueta, no cuando el cliente ya vive dentro.
El YAML: dos dimensiones que se multiplican
Aquí está el workflow de Reservo con las dos dimensiones. Es el de la lección 3 con dos cambios: una lista de os en la matriz, y runs-on leyendo esa lista en vez de estar fijo en ubuntu-latest.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }} # <- ya no es fijo: lo elige la celda
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest] # dimension 1
python-version: ["3.11", "3.12", "3.13"] # dimension 2
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
Dos piezas nuevas:
os: [ubuntu-latest, macos-latest, windows-latest] — una segunda dimensión de la matriz, con su propia lista. Los valores son los nombres de los runners que GitHub ofrece: ubuntu-latest (Linux), macos-latest (macOS), windows-latest (Windows). El nombre os lo eliges tú, igual que python-version.
runs-on: ${{ matrix.os }} — antes runs-on decía ubuntu-latest fijo; ahora lee el valor de la celda. En la celda de Windows se resuelve a runs-on: windows-latest y el job corre en una máquina Windows; en la de macOS, en un Mac. Así, la dimensión os no solo aparece en un nombre: cambia la máquina donde corre el job. Esta es la diferencia con la dimensión de versión, que cambiaba el Python instalado; la de os cambia el sistema entero debajo.
Y ahora la multiplicación. Con dos dimensiones, GitHub genera un job por cada combinación de un valor de cada lista. Tres sistemas × tres versiones = nueve jobs:
test (ubuntu-latest, 3.11) test (ubuntu-latest, 3.12) test (ubuntu-latest, 3.13)
test (macos-latest, 3.11) test (macos-latest, 3.12) test (macos-latest, 3.13)
test (windows-latest, 3.11) test (windows-latest, 3.12) test (windows-latest, 3.13)
Una cuadrícula. Cada celda es tu suite completa corriendo en esa combinación exacta de sistema y versión. Escribiste dos listas cortas —tres y tres— y obtuviste nueve corridas. Esta es la regla que gobierna el tamaño de toda matriz:
Con varias dimensiones, el número de jobs es el PRODUCTO de los tamaños de las listas, no la suma. 3 × 3 = 9, no 6. Agregar un valor a una lista de tres, teniendo otra lista de tres, no suma un job: suma tres.
Interiorizar esa multiplicación es la mitad de saber diseñar matrices. La otra mitad es saber cuáles de esas nueve celdas de verdad necesitas —lección 7—.
Qué cambia de verdad entre sistemas, medido
La lección 2 nombró las diferencias entre sistemas; aquí las medimos en la máquina real (macOS, que es un sistema POSIX como Linux) para que dejen de ser abstractas. Corramos un python -c que imprime los valores que cambian por sistema:
python -c "import os, sys; print('sys.platform =', sys.platform); print('os.name =', os.name); print('os.sep =', repr(os.sep)); print('os.linesep =', repr(os.linesep))"
Qué esperar. En macOS con Python 3.14.0, medido de verdad:
sys.platform = darwin
os.name = posix
os.sep = '/'
os.linesep = '\n'
Léelo valor por valor, y al lado lo que diría Windows (que no podemos correr aquí, pero cuyos valores son conocidos y documentados):
| Valor | macOS / Linux (medido) | Windows |
|---|---|---|
sys.platform | darwin (macOS), linux (Linux) | win32 |
os.name | posix | nt |
os.sep (separador de ruta) | / | \ |
os.linesep (salto de línea) | \n | \r\n |
Cuatro diferencias, cuatro fuentes de bugs solo-en-un-sistema:
os.sepes la más común. Si tu código arma una ruta con"reports" + "/" + archivo, en macOS dareports/archivo(bien) y en Windows debería serreports\archivo, pero tu/a mano no lo respeta. Un test que compare la ruta contra un string escrito con/pasará en macOS y fallará en Windows.os.linesepmuerde al escribir o comparar texto. Un archivo escrito "con los saltos del sistema" tiene\nen macOS y\r\nen Windows; unassert contenido == "a\nb\n"puede fallar en Windows por los\rextra.sys.platform/os.nameson los que usas para decidir comportamiento por sistema, igual quesys.version_infopara la versión. Unskipif(sys.platform == "win32", ...)salta un test en Windows.
La demo: un test que sobrevive a los tres sistemas
Reservo genera reportes, y un reporte se guarda en una ruta. Aquí está el test de esa ruta, escrito con cuidado para no romperse por el separador. Lo mantenemos como un archivo de demostración aparte del núcleo de la suite —en demo_os/—, porque prueba una preocupación de sistema operativo, no la lógica de negocio:
# demo_os/test_os_features.py
import os
import sys
import pytest
def test_report_path_uses_the_os_separator(tmp_path):
# Construir la ruta con os.path.join usa el separador del sistema:
# '/' en Linux/macOS, '\\' en Windows. El test vale en cualquier SO
# porque compara contra os.sep, no contra un separador escrito a mano.
path = os.path.join("reports", "2026-08", "daily.txt")
assert path == os.sep.join(["reports", "2026-08", "daily.txt"])
@pytest.mark.skipif(
sys.platform == "win32",
reason="en POSIX (Linux/macOS) el separador es '/'; en Windows seria '\\'",
)
def test_posix_separator_is_forward_slash():
assert os.sep == "/"
El primer test es el modelo a imitar: construye la ruta con os.path.join (que usa el separador correcto del sistema) y la compara contra os.sep.join(...) (que también usa el separador del sistema). Como los dos lados usan os.sep, el test es verdadero en cualquier sistema: en macOS ambos lados dan reports/2026-08/daily.txt, en Windows ambos darían reports\2026-08\daily.txt. No asume un separador; se adapta.
El segundo test usa skipif con sys.platform —la versión "por sistema operativo" del skipif por versión que ya conoces—. Solo tiene sentido en POSIX (donde el separador es /), así que se salta en Windows. Corrámoslos de verdad:
Ejemplo trabajado
python -m pytest -v -rs demo_os/test_os_features.py
Qué esperar. En macOS con Python 3.14.0, medido ejecutando:
============================= 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 2 items
demo_os/test_os_features.py::test_report_path_uses_the_os_separator PASSED [ 50%]
demo_os/test_os_features.py::test_posix_separator_is_forward_slash PASSED [100%]
============================== 2 passed in 0.01s ===============================
Los dos pasan en macOS. Fíjate en que el segundo, test_posix_separator_is_forward_slash, no se saltó: su condición de skip es sys.platform == "win32", y aquí sys.platform vale darwin, así que la condición es falsa y el test corre (y pasa, porque os.sep == "/" en macOS). En la celda de Windows, ese mismo test se saltaría —sys.platform valdría win32, la condición sería verdadera— y el resumen de esa celda diría 1 passed, 1 skipped. Otra vez el patrón del módulo: la misma suite, distinto detalle por celda, cada una probando lo que aplica a su terreno.
Ahora el contraste que justifica la matriz. Imagina que alguien hubiera escrito el primer test sin cuidado, así:
def test_report_path_bad():
path = os.path.join("reports", "2026-08", "daily.txt")
assert path == "reports/2026-08/daily.txt" # <- separador '/' escrito a mano
En macOS pasa (el os.path.join produce /), y quien lo escribió publica feliz. En la celda de Windows de la matriz, os.path.join produce reports\2026-08\daily.txt, que no es igual a reports/2026-08/daily.txt, y el test da rojo —solo en Windows—. Ese rojo es el valor entero de la dimensión os: sin ella, el bug viajaría a cualquier usuario de Windows sin que nadie lo supiera. Con ella, la celda test (windows-latest, 3.12) se pone roja, te dice exactamente dónde, y lo arreglas antes de publicar.
Cuándo la dimensión de SO importa (y cuándo no)
Un adelanto de la lección 7, porque aplica en particular al sistema operativo. La dimensión de os paga cuando tu código toca el sistema de archivos, escribe o lee texto con saltos de línea, maneja rutas, o depende de librerías con partes compiladas por sistema. Ahí las diferencias de terreno son reales y solo una matriz de SO las caza.
Pero no todo código toca el terreno. La lógica pura de Reservo —price_cents, refund_cents, overlaps, aritmética de enteros y comparaciones de fechas— da exactamente lo mismo en Linux, macOS y Windows: no hay rutas, no hay archivos, no hay saltos de línea. Para esa parte, correr la matriz de tres sistemas es gastar tres veces los minutos para obtener tres veces el mismo verde. La pregunta honesta no es "¿puedo probar en tres sistemas?" sino "¿mi código hace algo distinto en tres sistemas?". Si la respuesta es no, la dimensión de os es ruido. Reservo, tal como está, casi no la necesita —y esa honestidad es justo lo que la lección 7 te enseña a defender—.
Errores comunes
Creer que dos dimensiones se suman. Qué pasa: alguien pone tres sistemas y tres versiones esperando "unos seis jobs" y se sorprende con nueve corridas y el triple de minutos facturados. Por qué pasa: intuitivamente "3 y 3" suena a 6; pero la matriz hace el producto cartesiano, cada versión en cada sistema. Cómo detectarlo: multiplica los tamaños de todas tus listas antes de hacer push; ese producto es tu número de celdas. Cómo corregirlo: recuerda la regla —producto, no suma— y si el número asusta, recorta con exclude (lección 5) o reduce una lista según lo que de verdad necesitas (lección 7).
Pegar rutas con / y probar solo en Mac o Linux. Qué pasa: construyes rutas con carpeta + "/" + archivo o comparas contra strings con /, funciona en tu POSIX, y la celda de Windows se pone roja (o peor, no tienes esa celda y el bug llega al usuario). Por qué pasa: en tu sistema / es el separador, así que el error es invisible para ti. Cómo detectarlo: busca "/" y "\\" literales en código que arma o compara rutas. Cómo corregirlo: os.path.join o pathlib.Path para construir, y compara contra os.sep.join(...) o usando pathlib, nunca contra un separador escrito a mano.
Encender la dimensión de SO para código que no toca el sistema. Qué pasa: agregas os: [ubuntu, macos, windows] a una suite de lógica pura como la de Reservo, triplicando las celdas para probar aritmética que es idéntica en los tres sistemas. Por qué pasa: "más sistemas se siente más robusto". Cómo detectarlo: pregúntate por cada test "¿este resultado podría cambiar según el sistema operativo?". Si es aritmética, comparaciones o lógica pura, la respuesta es no. Cómo corregirlo: reserva la dimensión de os para el código que de verdad toca rutas, archivos, texto o binarios compilados; para el resto, una sola fila de sistema basta. La lección 7 te da el criterio completo.
Ejercicios
Ejercicio 1 — Cuenta las celdas. Para cada matriz, di cuántos jobs genera y escribe el nombre de dos de ellos: (a) os: [ubuntu-latest, windows-latest] y python-version: ["3.11", "3.12", "3.13", "3.14"]; (b) solo os: [ubuntu-latest, macos-latest, windows-latest], sin dimensión de versión; (c) os: [ubuntu-latest] y python-version: ["3.11", "3.12"].
Ver solución
- (a) 2 × 4 = 8 jobs. El producto de dos sistemas por cuatro versiones. Dos nombres de ejemplo:
test (ubuntu-latest, 3.11)ytest (windows-latest, 3.14). - (b) 3 jobs. Una sola dimensión de tres valores; sin dimensión de versión, no hay nada que multiplicar. Nombres:
test (ubuntu-latest),test (macos-latest)(ytest (windows-latest)). - (c) 1 × 2 = 2 jobs. Un sistema por dos versiones. Nombres:
test (ubuntu-latest, 3.11)ytest (ubuntu-latest, 3.12).
La regla que se repite: multiplica los tamaños de todas las listas. Una lista sola no se multiplica por nada, así que su número de jobs es su propio tamaño. Este cálculo es el que debes hacer antes de hacer push, porque es el número de corridas —y de minutos— por cada cambio.
Ejercicio 2 — Arregla el test que solo falla en Windows. Este test pasa en la máquina de quien lo escribió (un Mac) y la celda de Windows de la matriz lo pone rojo. Explica por qué falla en Windows y reescríbelo para que pase en los tres sistemas.
def test_report_dir():
base = "reports"
sub = "2026-08"
full = base + "/" + sub
assert full == os.path.join(base, sub)
Ver solución
Falla en Windows porque el lado izquierdo, base + "/" + sub, arma la ruta con un / escrito a mano, dando siempre reports/2026-08. Pero el lado derecho, os.path.join(base, sub), usa el separador del sistema: en Windows produce reports\2026-08. Entonces reports/2026-08 == reports\2026-08 es falso en Windows, y el test da rojo. En macOS pasa por casualidad, porque ahí el separador también es / y los dos lados coinciden.
El arreglo es no escribir el separador a mano; construir la ruta con la misma herramienta que respeta el sistema:
def test_report_dir():
base = "reports"
sub = "2026-08"
full = os.path.join(base, sub) # usa el separador del sistema
assert full == os.sep.join([base, sub]) # tambien, para comparar sin asumir
Ahora los dos lados usan os.sep, así que el test es verdadero en macOS (reports/2026-08) y en Windows (reports\2026-08) por igual. La lección: nunca escribas / ni \ a mano en una ruta que vas a comparar; deja que os.path.join/pathlib pongan el separador del terreno.
Ejercicio 3 — ¿Merece Reservo la dimensión de SO? Mira las dos partes de Reservo: (i) la lógica pura —price_cents, refund_cents, overlaps, book—, que es aritmética de enteros y comparaciones de fechas; (ii) el nuevo report_pages y la escritura de reportes a disco. Decide, para cada una, si vale la pena probarla en [ubuntu, macos, windows] o si una sola fila de sistema basta, y justifica.
Ver solución
- (i) La lógica pura → una sola fila de sistema basta.
price_cents,refund_cents,overlapsybookson aritmética de enteros y comparaciones dedatetime.2500 * 3da7500idéntico en Linux, macOS y Windows; no hay rutas, ni archivos, ni saltos de línea, ni binarios compilados de por medio. Probarla en tres sistemas daría tres veces el mismo verde, gastando el triple de minutos sin cazar nada. Para esta parte, la dimensión deoses ruido; una fila (por ejemploubuntu-latest) alcanza. - (ii) Escribir reportes a disco → sí vale la matriz de SO. En cuanto Reservo escribe un archivo (rutas, saltos de línea, encoding), el terreno importa: el separador
/vs\, el\nvs\r\n, el encoding por defecto. Ahí un test descuidado se rompe solo en Windows, y solo una celda de Windows lo caza antes del usuario. Para esta parte,[ubuntu, macos, windows]paga.
La conclusión honesta: Reservo, mientras sea lógica pura, casi no necesita la dimensión de SO; en cuanto toca el disco, sí. La matriz no se enciende por reflejo, se enciende donde el código de verdad toca el terreno. Este razonamiento —encender solo las celdas que cazan algo real— es el corazón de la lección 7.
Resumen y siguiente paso
En esta lección agregaste la segunda dimensión de la matriz —el sistema operativo— con os: [ubuntu-latest, macos-latest, windows-latest] y runs-on: ${{ matrix.os }}, que manda cada celda a su máquina. Y aprendiste la regla que gobierna el tamaño de toda matriz: con varias dimensiones, el número de jobs es el producto de los tamaños de las listas, no la suma —3 × 3 = 9, una cuadrícula—.
Mediste de verdad qué cambia entre sistemas: en macOS, sys.platform = darwin, os.name = posix, os.sep = '/', os.linesep = '\n', contra los valores de Windows (win32, nt, \, \r\n). Viste un test escrito con cuidado —con os.path.join y os.sep— pasar en los tres sistemas, y entendiste cómo uno descuidado, con / a mano, pasaría en tu Mac y se pondría rojo solo en Windows: exactamente el bug que la dimensión de SO existe para cazar. Y quedó la honestidad de fondo: la lógica pura de Reservo no cambia por sistema, así que esa dimensión paga solo cuando el código toca rutas, archivos o texto.
Antes de avanzar deberías poder: escribir una matriz de dos dimensiones; calcular cuántas celdas genera (producto, no suma); nombrar cuatro cosas que cambian entre sistemas operativos y cómo las mide Python; y decidir si un pedazo de código merece o no la dimensión de SO.
Lo que sigue, en la lección 5, es afinar esta cuadrícula sin escribir cada celda a mano: include para sumar celdas especiales, exclude para quitar las que no aplican, y fail-fast para decidir si la matriz se detiene al primer rojo o corre completa para mostrarte el mapa entero. Con nueve celdas sobre la mesa, empieza a importar cuáles quitas, cuáles agregas, y qué pasa cuando una falla.
Recursos
- Choosing the runner for a job — GitHub Actions — la lista de runners disponibles (
ubuntu-latest,macos-latest,windows-latest) y qué sistema es cada uno. Es la referencia de los valores que van en la listaos. os.sepyos.linesep— documentación de Python — los valores que medimos, definidos oficialmente. Léelos junto aos.path.join: entender que el separador depende del sistema es la clave para escribir código que sobrevive a la matriz de SO.sys.platform— documentación de Python — el identificador del sistema (linux,darwin,win32) que se usa enskipifpor sistema operativo, la contraparte desys.version_infopor versión.- Using a matrix for your jobs: example matrices — GitHub Actions — ejemplos de matrices multidimensionales con
osypython-version, el patrón exacto de esta lección. La sección de "expanding configurations" prepara el terreno parainclude/excludede la lección 5.