Module 4: The Matrix Versions And Environments
8. Mini-project: a 3-version matrix for Reservo
Description
The time has come to pull the whole module together into one deliverable. In the previous seven lessons you learned what a matrix is, against which dangers it protects you, how to write the version dimension and the system one, how to sculpt it with include/exclude/fail-fast, how to read its N results, and when it pays off. Now you apply it from start to finish: you configure a three-Python-version matrix for the Reservo suite, with its version-dependent feature, and you deliver the evidence that you understand it.
This isn't a stray exercise: it's the real work you'd do setting up the multi-version CI of a project. You're going to produce five concrete deliverables —the YAML workflow with strategy.matrix, the feature with @pytest.mark.skipif that behaves differently by version, the local run that shows 8 passed, 1 skipped with the evidence of which cell skips which test, the reading of the three results the matrix would produce, and a note that justifies which matrix Reservo really deserves—. All with the guide's honesty: the YAML is content (there's no runner here), but the pytest run is real, executed on Python 3.14.0.
Connection to the module: this lesson closes the arc. Lesson 1 gave you the concept and the demo; lesson 2, the catalog of differences; lesson 3, the version dimension; lesson 4, the system one; lesson 5, the sculpting; lesson 6, the reading; lesson 7, the judgment. The mini-project exercises them all at once on Reservo. And it looks ahead: by the end you'll have a matrix that runs your suite N times per push, which makes module 5's question urgent —how do I make it fast?— with cache and parallelism. You close the matrix; module 5 speeds it up.
The assignment
You're responsible for the CI of Reservo as a library —you publish it so other teams install it—. Your pyproject.toml declares requires-python = ">=3.11" and you promise to support Python 3.11, 3.12, and 3.13. Reservo includes report_pages, which uses itertools.batched (stdlib since 3.12) with a manual fallback for earlier versions. Your job:
- Write the GitHub Actions workflow that runs the suite on the three promised versions.
- Ensure the version-dependent feature is tested on both branches, each where it applies, with
skipif. - Run the suite locally (your witness cell) and capture the evidence.
- Describe how the three matrix results would read.
- Justify, with lesson 7's criteria, why this matrix —and not a bigger or smaller one— is the correct one for Reservo-as-a-library.
Try each step on your own before looking at the solution. The complete solution is at the end, but the learning is in building it yourself.
Step 1 — The workflow with strategy.matrix
Write .github/workflows/tests.yml. It must run on every push and PR, install each matrix version with setup-python, install dependencies, and run pytest. Remember: versions in quotes, and connect ${{ matrix.python-version }} to the setup-python step.
Think about it before continuing: how many jobs does your matrix generate? Which line makes each cell install a different version?
Step 2 — The feature with skipif on both branches
Reservo already has report_pages with its if sys.version_info >= (3, 12). Your suite must test both branches: the one that uses itertools.batched (applies on 3.12+) and the manual-fallback one (applies on < 3.12). Since no cell can test both at once —each version only runs one branch—, you use two mirror tests with skipif of opposite conditions, plus a test that holds on every version.
Think about it: which skipif condition skips the batched branch on 3.11? Which skips the fallback on 3.12+?
Step 3 — The local run (your witness cell)
Run the complete suite on your machine and capture the output. Your Python 3.14 plays one of the matrix cells. Use -rs so the skip reasons show —that evidence is part of the deliverable—.
Step 4 — Reading the three results
Without a runner, describe how the three matrix cells would look in the CI log: their names, their count, and —the module's fine detail— which test skips on each version.
Step 5 — The decision note
Justify the matrix's size. Why three versions and not one? Why (or why not) the operating-system dimension? Apply the "ship + promise, nothing more" rule.
Complete solution
Deliverable 1 — The YAML workflow
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # we want to see all three cells, not cancel at the first red
matrix:
python-version: ["3.11", "3.12", "3.13"] # exactly what the README promises
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
The decisions and their why:
python-version: ["3.11", "3.12", "3.13"], in quotes, generates three jobs. The list is the README's promise: not one version too many, not one too few. (Modules 3 and 7.)${{ matrix.python-version }}insetup-pythonconnects the matrix to the step that installs Python; without that line, all three cells would run the same version. (Lesson 3.)fail-fast: falsebecause, being a library, I want the complete map of results: if one version fails, I want to know if the others do too, not have them cancelled at the first red. (Lesson 5.)- A single system row (
runs-on: ubuntu-latest, noosdimension), a decision I justify in the deliverable 5 note.
The requirements.txt the workflow installs, for Reservo:
# requirements.txt
pytest==9.1.1
Reservo is pure stdlib, so the only dependency is pytest to run the tests. Pinning it (==9.1.1) is module 3's lesson: deterministic installs so the cell runs the same as you.
Deliverable 2 — The feature and its tests with skipif
The feature's code (reservo/reports.py), which already chooses the branch by version:
# reservo/reports.py
import sys
if sys.version_info >= (3, 12):
from itertools import batched
def report_pages(bookings, size):
"""Group the bookings into pages of `size` for the daily report."""
return [list(page) for page in batched(bookings, size)]
else:
def report_pages(bookings, size):
"""Manual fallback for Python < 3.12, where itertools.batched doesn't exist."""
return [bookings[i:i + size] for i in range(0, len(bookings), size)]
The tests (tests/test_version_features.py), with both branches covered:
# tests/test_version_features.py
import sys
import pytest
from reservo.reports import report_pages
def test_report_pages_groups_bookings():
# Rule that holds in EVERY version: 5 bookings in pages of 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 is part of the stdlib only since 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="the manual fallback is only exercised on Python < 3.12",
)
def test_report_pages_manual_fallback_on_old_python():
assert "batched" not in dir(__import__("itertools"))
The key: the two skipif are mirrors. test_report_pages_uses_stdlib_batched tests the batched branch and skips on 3.11 (where batched doesn't exist). test_report_pages_manual_fallback_on_old_python tests the fallback branch and skips on 3.12+. Between the 3.11 and 3.12+ cells, both branches of report_pages end up really exercised, each where it lives. The third test, without skipif, verifies the visible behavior that must be identical in every version —the safety net that catches if some branch deviates from the contract—.
Deliverable 3 — The local run, executed for real
python -m pytest -v -rs tests/
What to expect. On Python 3.14.0 with pytest 9.1.1, measured by executing (this is the real output, not a mockup):
============================= 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: the manual fallback is only exercised on Python < 3.12
========================= 8 passed, 1 skipped in 0.01s =========================
The key evidence, pointed out:
platform darwin -- Python 3.14.0— the witness cell. It runs 3.14, which exercises the same branch as the 3.12 and 3.13 cells (all ≥ 3.12).8 passed, 1 skipped— eight pass, one skips. The eight greens include the three price anchor numbers (7500, 6000, 2500) and the three refund ones (6000, 3000, 0), plus the universalreport_pagestest and thebatchedbranch.- The
SKIPPEDistest_report_pages_manual_fallback_on_old_python, and its reason —the manual fallback is only exercised on Python < 3.12— is printed thanks to-rs. On 3.14 (≥ 3.12), that branch doesn't apply, so the test skips cleanly: it neither passes pretending, nor fails over something irrelevant.
The anchor numbers, verified in this run (part of the deliverable, because they confirm the suite tests the right thing):
| Test | Rule | Result |
|---|---|---|
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 | cancel 72 h ahead (≥48 h) | 6000 |
test_half_refund_36h_before | cancel 36 h ahead (24–48 h) | 3000 |
test_no_refund_12h_before | cancel 12 h ahead (<24 h) | 0 |
Deliverable 4 — How the three matrix results would read
In CI, the matrix would produce three cells. This is how the job list would look, all green (honest log format; the per-cell count is what the cell would report):
tests · push to 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
All three say 8 passed, 1 skipped, but —and this is what you have to understand— the test that skips is different on 3.11 than on 3.12/3.13:
| Cell | Branch it exercises | Test that runs | Test that skips |
|---|---|---|---|
test (3.11) | manual fallback | 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 |
On 3.11 the fallback is exercised (and the batched branch, which doesn't exist there, is skipped). On 3.12 and 3.13 batched is exercised (and the fallback is skipped). Between the three cells, both branches of report_pages ended up really tested, each in the version where it lives. That's what a single job couldn't give you: it would test one branch and leave the other untouched.
And if a cell turned red —say test (3.11) with an ImportError: cannot import name 'batched'—, the pattern (a version cell) and the name (test (3.11)) would tell me at once that the bug is 3.11's: someone used batched without the fallback. The fix would be to restore the fallback; the local reproduction, install 3.11 and run the suite (module 3).
Deliverable 5 — The decision note: which matrix does Reservo deserve?
Reservo-as-a-library deserves the three-Python-version matrix on a single system row. The reasoning, with the "ship + promise, nothing more" rule:
- Three Python versions: they do pay off. Reservo is a library others install, and its
pyproject.toml/README promise 3.11, 3.12, and 3.13. Each is a promise to users I don't control. Also, Reservo uses a feature that varies by version (itertools.batched, with two code branches): without the three cells, one of the branches would go unexercised in the version where it lives. The version dimension isn't reflex, it's exactly the promise made verifiable. - The operating-system dimension: it doesn't pay off (for now). Reservo's core logic —
price_cents,refund_cents,overlaps,book— is integer arithmetic and date comparisons: it gives identical on Linux, macOS, and Windows. Andreport_pages, though it varies by version, doesn't touch paths or files or line endings. There's nothing in the current code that behaves differently by system, so a 3×3 matrix would run nine cells to get six times the same green. It would be lesson 7's "fleet insurance for a bike": cost (nine runs per push, with macOS and Windows more expensive) and noise, without catching a single bug the Linux row doesn't already catch. - The trigger to add the OS dimension. The day Reservo writes reports to disk —paths, encoding, line endings—, that's when
os: [ubuntu-latest, windows-latest]would come in (POSIX vs Windows, the real difference; macOS is almost redundant with Linux for this). Until then, adding it would be advancing a cost without coverage.
Conclusion: three cells (3.11, 3.12, 3.13 on Linux), with fail-fast: false to see the complete map. It's the matrix that corresponds to what Reservo really risks as a library: the versions it promises, on the only axis where its code varies today. Neither the nine cells of a reflex 3×3, nor a single cell that would leave two promised versions untested.
Common mistakes
Delivering the YAML without the local-run evidence. What happens: someone writes a correct strategy.matrix but doesn't run the suite even once, so they don't know if it really passes or what skips. Why it happens: the YAML "looks fine" and gives the feeling of finished work. How to spot it: if you don't have a pytest output with 8 passed, 1 skipped (or whatever the count is), you've verified nothing, only written intentions. How to fix it: run the suite on your witness cell and capture the output; that evidence is half the deliverable, because the YAML is content and the run is what's real.
Covering only one branch of the version feature. What happens: the itertools.batched test is written but not the fallback one, so on 3.11 the fallback branch is never tested —it stays green by omission—. Why it happens: on your machine (3.12+) you only see the batched branch, and it's easy to forget the other. How to spot it: for each if sys.version_info in the code, ask yourself "do I have a test for each branch, with skipif that runs it where it applies?". How to fix it: the two mirror tests, with opposite conditions, so that between the matrix cells both branches end up exercised.
Inflating Reservo's matrix to 3×3 "to be safe". What happens: os: [ubuntu, macos, windows] is added to the version matrix, going up to nine cells, even though Reservo's code touches nothing system-specific. Why it happens: more cells feels more robust. How to spot it: ask of the OS dimension "what bug does it catch that the Linux row wouldn't?"; for pure logic, the answer is none. How to fix it: keep a single system row while the code is pure logic, and document in a comment why; add the OS dimension only when the code starts touching the disk. (Lesson 7.)
Exercises
Exercise 1 — Add a version to the promise. Reservo decides to also support Python 3.14. Modify the workflow's strategy.matrix section to reflect the new promise, and say how many jobs it generates now and which test would skip in the new cell.
See solution
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
Now the list has four versions, so it generates four jobs: test (3.11), test (3.12), test (3.13), test (3.14). The new cell, test (3.14), is ≥ 3.12, so it exercises the itertools.batched branch —like 3.12 and 3.13— and skips test_report_pages_manual_fallback_on_old_python (the fallback, which only applies on < 3.12). Its count would be 8 passed, 1 skipped, with the same skipped test as the other 3.12+ cells. This is exactly the run you did locally (your machine is 3.14), so you already have its evidence: platform darwin -- Python 3.14.0 ... 8 passed, 1 skipped. Adding a version to the list added one cell (it didn't multiply, because there's a single dimension), and the README's promise should be updated to "3.11–3.14" so matrix and promise match.
Exercise 2 — The library does need the OS dimension. Imagine Reservo-the-library adds a function export_report(path) that writes the report to a text file, with line endings. Now justify: should the matrix grow? To what? Write the new strategy.matrix and say how many cells result.
See solution
Yes, the matrix should grow, because now Reservo touches the system's terrain: writing a text file involves the path separator (for path) and the line endings (\n vs \r\n), which differ by operating system (lesson 4). A library that promises Linux/macOS/Windows must verify that export_report works on all three. The new strategy.matrix:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest] # POSIX vs Windows: the real difference
python-version: ["3.11", "3.12", "3.13"]
2 × 3 = 6 cells result. Note lesson 7's decision: I included ubuntu and windows but not macos, because for the differences that matter here —separator and line endings— macOS behaves like Linux (both POSIX, / and \n), so the macOS cell would be almost redundant with the Linux one. Two systems cover the real difference (POSIX vs Windows) with six cells, instead of three systems with nine. If the team wanted to be exhaustive or had macOS users reporting bugs, adding macos-latest (nine cells) would be defensible; to start, six cover the real risk with less cost. The "test what you risk" rule includes not paying for the nearly redundant cell.
Exercise 3 — Detect the untested branch. A teammate delivers this suite for the version feature. It runs 8 passed, 1 skipped on their machine (3.13) and on yours (3.14). Which branch of report_pages goes actually untested across the whole 3.11/3.12/3.13 matrix, and how do you fix it?
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 only since 3.12",
)
def test_report_pages_uses_stdlib_batched():
from itertools import batched
assert list(batched("abcde", 2)) == [("a", "b"), ("c", "d"), ("e",)]
See solution
The manual-fallback branch test is missing (the else one, which runs on Python < 3.12). This suite has the universal test (test_report_pages_groups_bookings) and the batched branch one (with a skipif that skips it on 3.11), but not the mirror test that specifically verifies that on 3.11 the fallback is used. Result: in the 3.11 cell, test_report_pages_uses_stdlib_batched skips, and no test remains to confirm the fallback was exercised —only the universal test, which passes through the fallback branch but doesn't assert it's the fallback—. The else branch is tested only glancingly.
Why it's easy not to notice: on both their machines (3.13 and 3.14, ≥ 3.12), the fallback branch never runs, so the gap isn't visible locally; the 8 passed, 1 skipped looks identical. Only the matrix's 3.11 cell would exercise the fallback, and without a dedicated test, no one confirms it.
The fix is to add the mirror test with the opposite condition:
@pytest.mark.skipif(
sys.version_info >= (3, 12),
reason="the manual fallback is only exercised on Python < 3.12",
)
def test_report_pages_manual_fallback_on_old_python():
assert "batched" not in dir(__import__("itertools"))
Now the 3.11 cell runs this test (confirming that there batched really doesn't exist and the fallback is used) and skips the batched one; the 3.12+ cells do the inverse. With the two mirrors, both branches end up tested, each in the version where it lives. The lesson: for each if sys.version_info in the code, you need a test per branch, with skipif that runs it where it applies —and the matrix that includes at least one version on each side of the boundary—.
Summary and next step
In this mini-project you configured, from start to finish, a three-Python-version matrix for the Reservo suite, and delivered the evidence that you understand it: the YAML workflow with strategy.matrix (three versions in quotes, ${{ matrix.python-version }} connected, fail-fast: false to see the complete map), the feature with skipif in two mirror tests that test both branches of report_pages each where it lives, the real local run —8 passed, 1 skipped on Python 3.14.0, with the skip reason printed—, the reading of the three results with the detail of which test skips by version, and the decision note that justifies why three versions and a single system row is the matrix Reservo-as-a-library really deserves.
This closes the module. Now you know why a single green environment isn't enough, how to write the version dimension and the system one, how to sculpt the grid with include/exclude/fail-fast, how to read the N results to locate a bug, and —what separates someone who copies a matrix from someone who designs one— when the matrix pays off and when it's noise. The matrix turned "it works on my machine" into an honest table of where it works.
What's next, in module 5, is the direct consequence of having a matrix: speed. A matrix runs your suite N times per push, and if each cell installs dependencies from scratch and runs the tests serially, the feedback cycle lengthens exactly when you use it most. Module 5 attacks it with dependency caching and parallelism (pytest-xdist), executed for real locally —so the matrix you just built doesn't become a bottleneck—.
Resources
- Using a matrix for your jobs — GitHub Actions — the complete reference you exercised in this project: single-dimension matrix,
include/exclude,fail-fast. Come back to it when you build your own project's matrix. actions/setup-python: matrix testing — the officialsetup-pythonpattern within a version matrix, identical to the workflow you wrote. Its README documents the accepted version formats.pytest.mark.skipifandreason— pytest documentation — the reference for the mirrorskipifthat cover the feature's two branches. Pay attention to why thereason(that-rsprints) makes each skip auditable.itertools.batched— Python documentation — the stdlib feature, with its "Added in version 3.12" that's the reason for the whole version demo. The next module (speed) starts from the matrix this project left built.