Source code for tests.test_matrix

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests for `repomatic.github.matrix`: the workflow matrix model."""

from __future__ import annotations

from collections import Counter
from itertools import permutations

import pytest

from repomatic.github.matrix import Matrix


[docs] def test_matrix_constructor_takes_no_arguments(): """A matrix is populated through its methods, never through the constructor. The signature used to swallow `*args, **kwargs`, so `Matrix({"os": [...]})` returned an empty matrix instead of raising. """ with pytest.raises(TypeError): Matrix({"os": ["ubuntu-slim"]}) # type: ignore[call-arg]
[docs] def test_matrix(): """Construction, deduplicating `add_variation`, rendering, and rejections. `include` and `exclude` are reserved: they name the directive lists, so accepting them as axis names would shadow the very keys `solve` reads. """ matrix = Matrix() assert isinstance(matrix, Matrix) assert not isinstance(matrix, dict) assert hasattr(matrix, "variations") assert isinstance(matrix.variations, dict) assert hasattr(matrix, "include") assert hasattr(matrix, "exclude") assert matrix.include == () assert matrix.exclude == () matrix.add_variation("foo", ["a", "b", "c"]) assert matrix.variations == {"foo": ("a", "b", "c")} assert not matrix.include assert not matrix.exclude # Natural deduplication. matrix.add_variation("foo", ["a", "a", "d"]) assert matrix.variations == {"foo": ("a", "b", "c", "d")} assert not matrix.include assert not matrix.exclude assert matrix.matrix() == {"foo": ("a", "b", "c", "d")} assert str(matrix) == '{"foo": ["a", "b", "c", "d"]}' assert repr(matrix) == "<Matrix: FrozenDict({'foo': ('a', 'b', 'c', 'd')})>" with pytest.raises(ValueError): matrix.add_variation("variation_1", None) # type: ignore[arg-type] with pytest.raises(ValueError): matrix.add_variation("variation_1", []) with pytest.raises(ValueError): matrix.add_variation("variation_1", [None]) # type: ignore[list-item] with pytest.raises(ValueError): matrix.add_variation("include", ["a", "b", "c"]) with pytest.raises(ValueError): matrix.add_variation("exclude", ["a", "b", "c"])
[docs] def test_replace_variation_value(): """`replace` swaps one axis value for another, in place.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26", "windows-2025"]) matrix.add_variation("version", ["3.10", "3.14"]) # Basic replacement preserves position. matrix.replace_variation_value("os", "ubuntu-slim", "ubuntu-24.04") assert matrix.variations["os"] == ("ubuntu-24.04", "macos-26", "windows-2025") # Other axes are untouched. assert matrix.variations["version"] == ("3.10", "3.14") # Missing axis is a silent no-op. matrix.replace_variation_value("nonexistent", "a", "b") assert "nonexistent" not in matrix.variations # Missing value within an existing axis is a silent no-op. matrix.replace_variation_value("os", "linux-latest", "ubuntu-22.04") assert matrix.variations["os"] == ("ubuntu-24.04", "macos-26", "windows-2025") # Replacement deduplicates when the new value already exists. matrix.replace_variation_value("os", "windows-2025", "macos-26") assert matrix.variations["os"] == ("ubuntu-24.04", "macos-26")
[docs] def test_remove_variation_value(): """`remove` drops an axis value, and the axis once it empties.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26", "windows-2025"]) matrix.add_variation("version", ["3.10", "3.14"]) # Basic removal. matrix.remove_variation_value("os", "ubuntu-slim") assert matrix.variations["os"] == ("macos-26", "windows-2025") # Other axes are untouched. assert matrix.variations["version"] == ("3.10", "3.14") # Missing axis is a silent no-op. matrix.remove_variation_value("nonexistent", "a") assert "nonexistent" not in matrix.variations # Missing value within an existing axis is a silent no-op. matrix.remove_variation_value("os", "linux-latest") assert matrix.variations["os"] == ("macos-26", "windows-2025") # Removing all values deletes the axis. matrix.remove_variation_value("os", "macos-26") matrix.remove_variation_value("os", "windows-2025") assert "os" not in matrix.variations
[docs] def test_prune(caplog): """Prune removes no-op excludes and logs about them.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-24.04", "macos-26"]) matrix.add_variation("version", ["3.10", "3.14"]) # Effective exclude: os value exists in the axis. matrix.add_excludes({"os": "macos-26", "version": "3.10"}) # No-op exclude: os value does not exist in the axis. matrix.add_excludes({"os": "windows-11-arm"}) # No-op exclude: version value does not exist in the axis. matrix.add_excludes({"version": "3.15t"}) # No-op exclude: key not in any axis. GitHub Actions rejects excludes # referencing non-existent matrix keys, so prune must drop these too. matrix.add_excludes({"state": "unstable"}) assert len(matrix.exclude) == 4 matrix.prune() # Only the effective exclude remains. Reassign with an explicit # annotation to widen the type back to a variable-length tuple. The # ``assert len(...) == 4`` above narrows ``matrix.exclude`` to a # fixed-length 4-tuple; mypy cannot track the mutation performed by # ``prune()``, so without this it considers the ``len(...) == 1`` # assert unreachable. exclude: tuple[dict[str, str], ...] = matrix.exclude assert len(exclude) == 1 assert {"os": "macos-26", "version": "3.10"} in exclude assert "Dropping no-op exclude" in caplog.text assert "'windows-11-arm'" in caplog.text assert "'3.15t'" in caplog.text assert "'state'" in caplog.text
[docs] def test_remove_variation_value_solve(): """Exclude prevents resurrection; removal also strips the axis value.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26", "windows-11-arm"]) matrix.add_variation("version", ["3.14", "3.15"]) matrix.add_includes({"state": "stable"}) matrix.add_includes({"state": "unstable", "version": "3.15"}) matrix.add_excludes({"os": "windows-11-arm"}) # The partial unstable include augments the surviving 3.15 jobs; it does not # resurrect the excluded windows-11-arm × 3.15 combination (GitHub's # behavior). windows-11-arm stays a declared axis value, only its # combinations are excluded. solved_with_exclude = tuple(matrix.solve()) assert all(j["os"] != "windows-11-arm" for j in solved_with_exclude) assert len(solved_with_exclude) == 4 assert {"os": "macos-26", "version": "3.15", "state": "unstable"} in ( solved_with_exclude ) assert "windows-11-arm" in matrix.variations["os"] # Removal additionally strips the value from the axis entirely. matrix.remove_variation_value("os", "windows-11-arm") solved_with_remove = tuple(matrix.solve()) assert all(j["os"] != "windows-11-arm" for j in solved_with_remove) assert len(solved_with_remove) == 4 assert "windows-11-arm" not in matrix.variations["os"]
[docs] def test_solve_matches_github_include_example(): """solve() reproduces GitHub's documented matrix include expansion. The canonical example from [GitHub's docs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations#example-expanding-configurations): later includes augment base combinations only, never combinations a previous include created, and an include that matches no combination is appended. """ matrix = Matrix() matrix.add_variation("fruit", ["apple", "pear"]) matrix.add_variation("animal", ["cat", "dog"]) matrix.add_includes({"color": "green"}) matrix.add_includes({"color": "pink", "animal": "cat"}) matrix.add_includes({"fruit": "apple", "shape": "circle"}) matrix.add_includes({"fruit": "banana"}) matrix.add_includes({"fruit": "banana", "animal": "cat"}) assert tuple(matrix.solve()) == ( {"fruit": "apple", "animal": "cat", "color": "pink", "shape": "circle"}, {"fruit": "apple", "animal": "dog", "color": "green", "shape": "circle"}, {"fruit": "pear", "animal": "cat", "color": "pink"}, {"fruit": "pear", "animal": "dog", "color": "green"}, {"fruit": "banana"}, {"fruit": "banana", "animal": "cat"}, )
[docs] def test_solve_partial_include_does_not_resurrect_excluded(): """A partial include augments survivors without resurrecting excluded combos.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26", "windows-2025"]) matrix.add_variation("python-version", ["3.10", "3.15"]) # Carve the 3.15 probe down to a single runner. matrix.add_excludes( {"os": "macos-26", "python-version": "3.15"}, {"os": "windows-2025", "python-version": "3.15"}, ) # A partial include flags every surviving 3.15 job unstable. matrix.add_includes({"state": "unstable", "python-version": "3.15"}) solved = tuple(matrix.solve()) # Only ubuntu-slim keeps a 3.15 job; the excluded ones stay gone. py315 = [j for j in solved if j["python-version"] == "3.15"] assert py315 == [ {"os": "ubuntu-slim", "python-version": "3.15", "state": "unstable"} ] assert len(solved) == 4
[docs] def test_solve_full_include_resurrects_excluded(): """A fully-specified include adds back an excluded combination (GitHub behavior).""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26"]) matrix.add_variation("python-version", ["3.10", "3.14"]) matrix.add_excludes({"os": "macos-26", "python-version": "3.14"}) # An include that re-specifies the excluded cell brings it back. matrix.add_includes({"os": "macos-26", "python-version": "3.14", "extra": "yes"}) solved = tuple(matrix.solve()) assert {"os": "macos-26", "python-version": "3.14", "extra": "yes"} in solved assert len(solved) == 4
[docs] def test_replace_variation_value_solve(): """Replacement integrates correctly with the full solve pipeline.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26"]) matrix.add_variation("version", ["3.10", "3.14"]) matrix.add_includes({"state": "stable"}) matrix.add_excludes({"os": "ubuntu-slim", "version": "3.10"}) matrix.replace_variation_value("os", "ubuntu-slim", "ubuntu-24.04") # The exclude still references "ubuntu-slim", which no longer exists in the # axis, so the combination is no longer excluded. assert tuple(matrix.solve()) == ( {"os": "ubuntu-24.04", "version": "3.10", "state": "stable"}, {"os": "ubuntu-24.04", "version": "3.14", "state": "stable"}, {"os": "macos-26", "version": "3.10", "state": "stable"}, {"os": "macos-26", "version": "3.14", "state": "stable"}, )
[docs] def test_includes(): """`add_includes` accumulates directives without touching the axes.""" matrix = Matrix() matrix.add_variation("foo", ["a", "b", "c"]) assert matrix.matrix() == {"foo": ("a", "b", "c")} # First addition. matrix.add_includes({"foo": "a", "bar": "1"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "include": ({"foo": "a", "bar": "1"},), } # Second addition is cumulative. matrix.add_includes({"foo": "b", "bar": "2"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "include": ({"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}), } # Deduplication. matrix.add_includes({"foo": "b", "bar": "2"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "include": ({"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}), } # Rendering. assert ( str(matrix) == '{"foo": ["a", "b", "c"], ' '"include": [{"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}]}' ) assert ( repr(matrix) == "<Matrix: FrozenDict({'foo': ('a', 'b', 'c'), " "'include': ({'foo': 'a', 'bar': '1'}, {'foo': 'b', 'bar': '2'})})>" ) # Multiple insertions. matrix.add_includes({"foo": "c", "bar": "3"}, {"foo": "d", "bar": "4"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "include": ( {"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}, {"foo": "c", "bar": "3"}, {"foo": "d", "bar": "4"}, ), } # Forbidden values. with pytest.raises(ValueError): matrix.add_includes({"include": "random"}) with pytest.raises(ValueError): matrix.add_includes({"exclude": "random"})
[docs] def test_excludes(): """`add_excludes` accumulates directives without touching the axes.""" matrix = Matrix() matrix.add_variation("foo", ["a", "b", "c"]) assert matrix.matrix() == {"foo": ("a", "b", "c")} # First addition. matrix.add_excludes({"foo": "a", "bar": "1"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "exclude": ({"foo": "a", "bar": "1"},), } # Second addition is cumulative. matrix.add_excludes({"foo": "b", "bar": "2"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "exclude": ({"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}), } # Deduplication. matrix.add_excludes({"foo": "b", "bar": "2"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "exclude": ({"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}), } # Rendering. assert ( str(matrix) == '{"foo": ["a", "b", "c"], ' '"exclude": [{"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}]}' ) assert ( repr(matrix) == "<Matrix: FrozenDict({'foo': ('a', 'b', 'c'), " "'exclude': ({'foo': 'a', 'bar': '1'}, {'foo': 'b', 'bar': '2'})})>" ) # Multiple insertions. matrix.add_excludes({"foo": "c", "bar": "3"}, {"foo": "d", "bar": "4"}) assert matrix.matrix() == { "foo": ("a", "b", "c"), "exclude": ( {"foo": "a", "bar": "1"}, {"foo": "b", "bar": "2"}, {"foo": "c", "bar": "3"}, {"foo": "d", "bar": "4"}, ), } # Forbidden values. with pytest.raises(ValueError): matrix.add_excludes({"include": "random"}) with pytest.raises(ValueError): matrix.add_excludes({"exclude": "random"})
[docs] def test_all_variations(): """`all_variations` reports every axis and its values.""" matrix = Matrix() assert matrix.all_variations() == {} matrix.add_variation("foo", ["a", "b", "c"]) matrix.add_variation("bar", ["1", "2", "3"]) matrix.add_includes( {"foo": "b", "color": "green"}, {"foo": "d", "color": "orange"}, {"bar": "1", "shape": "triangle"}, {"size": "small"}, ) matrix.add_excludes( {"foo": "b", "shape": "circle"}, {"bar": "2", "color": "blue"}, {"bar": "4", "color": "yellow"}, {"weight": "heavy"}, ) assert matrix.matrix() == { "foo": ("a", "b", "c"), "bar": ("1", "2", "3"), "include": ( {"foo": "b", "color": "green"}, {"foo": "d", "color": "orange"}, {"bar": "1", "shape": "triangle"}, {"size": "small"}, ), "exclude": ( {"foo": "b", "shape": "circle"}, {"bar": "2", "color": "blue"}, {"bar": "4", "color": "yellow"}, {"weight": "heavy"}, ), } assert matrix.matrix(ignore_includes=True) == { "foo": ("a", "b", "c"), "bar": ("1", "2", "3"), "exclude": ( {"foo": "b", "shape": "circle"}, {"bar": "2", "color": "blue"}, {"bar": "4", "color": "yellow"}, {"weight": "heavy"}, ), } assert matrix.matrix(ignore_excludes=True) == { "foo": ("a", "b", "c"), "bar": ("1", "2", "3"), "include": ( {"foo": "b", "color": "green"}, {"foo": "d", "color": "orange"}, {"bar": "1", "shape": "triangle"}, {"size": "small"}, ), } assert matrix.matrix(ignore_includes=True, ignore_excludes=True) == { "foo": ("a", "b", "c"), "bar": ("1", "2", "3"), } assert matrix.all_variations() == { "foo": ("a", "b", "c"), "bar": ("1", "2", "3"), } assert matrix.all_variations(with_includes=True) == { "foo": ("a", "b", "c", "d"), "bar": ("1", "2", "3"), "color": ("green", "orange"), "shape": ("triangle",), "size": ("small",), } assert matrix.all_variations(with_excludes=True) == { "foo": ("a", "b", "c"), "bar": ("1", "2", "3", "4"), "shape": ("circle",), "color": ("blue", "yellow"), "weight": ("heavy",), } assert matrix.all_variations(with_includes=True, with_excludes=True) == { "foo": ("a", "b", "c", "d"), "bar": ("1", "2", "3", "4"), "color": ("green", "orange", "blue", "yellow"), "shape": ("triangle", "circle"), "size": ("small",), "weight": ("heavy",), } assert matrix.all_variations(with_matrix=False) == {} assert ( matrix.all_variations( with_matrix=False, with_excludes=False, with_includes=False ) == {} ) assert matrix.all_variations(with_matrix=False, with_includes=True) == { "foo": ("b", "d"), "color": ("green", "orange"), "bar": ("1",), "shape": ("triangle",), "size": ("small",), } assert matrix.all_variations(with_matrix=False, with_excludes=True) == { "foo": ("b",), "shape": ("circle",), "bar": ("2", "4"), "color": ("blue", "yellow"), "weight": ("heavy",), }
[docs] def test_product(): """`product` is the plain cartesian product, before include/exclude.""" matrix = Matrix() assert tuple(matrix.product()) == () matrix.add_variation("foo", ["a", "b"]) matrix.add_variation("bar", ["1", "2"]) assert tuple(matrix.product()) == ( {"foo": "a", "bar": "1"}, {"foo": "a", "bar": "2"}, {"foo": "b", "bar": "1"}, {"foo": "b", "bar": "2"}, ) matrix.add_includes( {"foo": "b", "baz": "W"}, {"foo": "c", "baz": "X"}, {"bar": "1", "qux": "福"}, {"@": "$"}, ) matrix.add_excludes( {"foo": "b", "qux": "子"}, {"bar": "2", "baz": "Y"}, {"bar": "3", "baz": "Z"}, {"E": "O"}, ) assert tuple(matrix.product()) == ( {"foo": "a", "bar": "1"}, {"foo": "a", "bar": "2"}, {"foo": "b", "bar": "1"}, {"foo": "b", "bar": "2"}, ) assert tuple(matrix.product(with_includes=True)) == ( {"foo": "a", "bar": "1", "baz": "W", "qux": "福", "@": "$"}, {"foo": "a", "bar": "1", "baz": "X", "qux": "福", "@": "$"}, {"foo": "a", "bar": "2", "baz": "W", "qux": "福", "@": "$"}, {"foo": "a", "bar": "2", "baz": "X", "qux": "福", "@": "$"}, {"foo": "b", "bar": "1", "baz": "W", "qux": "福", "@": "$"}, {"foo": "b", "bar": "1", "baz": "X", "qux": "福", "@": "$"}, {"foo": "b", "bar": "2", "baz": "W", "qux": "福", "@": "$"}, {"foo": "b", "bar": "2", "baz": "X", "qux": "福", "@": "$"}, {"foo": "c", "bar": "1", "baz": "W", "qux": "福", "@": "$"}, {"foo": "c", "bar": "1", "baz": "X", "qux": "福", "@": "$"}, {"foo": "c", "bar": "2", "baz": "W", "qux": "福", "@": "$"}, {"foo": "c", "bar": "2", "baz": "X", "qux": "福", "@": "$"}, ) assert tuple(matrix.product(with_excludes=True)) == ( {"foo": "a", "bar": "1", "qux": "子", "baz": "Y", "E": "O"}, {"foo": "a", "bar": "1", "qux": "子", "baz": "Z", "E": "O"}, {"foo": "a", "bar": "2", "qux": "子", "baz": "Y", "E": "O"}, {"foo": "a", "bar": "2", "qux": "子", "baz": "Z", "E": "O"}, {"foo": "a", "bar": "3", "qux": "子", "baz": "Y", "E": "O"}, {"foo": "a", "bar": "3", "qux": "子", "baz": "Z", "E": "O"}, {"foo": "b", "bar": "1", "qux": "子", "baz": "Y", "E": "O"}, {"foo": "b", "bar": "1", "qux": "子", "baz": "Z", "E": "O"}, {"foo": "b", "bar": "2", "qux": "子", "baz": "Y", "E": "O"}, {"foo": "b", "bar": "2", "qux": "子", "baz": "Z", "E": "O"}, {"foo": "b", "bar": "3", "qux": "子", "baz": "Y", "E": "O"}, {"foo": "b", "bar": "3", "qux": "子", "baz": "Z", "E": "O"}, ) assert tuple(matrix.product(with_includes=True, with_excludes=True)) == ( {"foo": "a", "bar": "1", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "1", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "2", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "a", "bar": "3", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "1", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "2", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "b", "bar": "3", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "1", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "2", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "W", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "W", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "X", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "X", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "Y", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "Y", "qux": "子", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "Z", "qux": "福", "@": "$", "E": "O"}, {"foo": "c", "bar": "3", "baz": "Z", "qux": "子", "@": "$", "E": "O"}, )
[docs] def test_solve_includes_are_order_sensitive(): """Later includes overwrite non-axis values added by earlier ones. GitHub applies includes sequentially, so `{color: green}` (added to every combination) overwrites the `color: pink` an earlier, more specific include set on the cat combinations. Reversing the two would keep pink. See {func}`test_solve_matches_github_include_example` for the documented order. """ matrix = Matrix() matrix.add_variation("fruit", ["apple", "pear"]) matrix.add_variation("animal", ["cat", "dog"]) matrix.add_includes( {"color": "pink", "animal": "cat"}, {"color": "green"}, ) assert tuple(matrix.solve()) == ( {"fruit": "apple", "animal": "cat", "color": "green"}, {"fruit": "apple", "animal": "dog", "color": "green"}, {"fruit": "pear", "animal": "cat", "color": "green"}, {"fruit": "pear", "animal": "dog", "color": "green"}, )
[docs] def test_solve_expanded_configuration(): """GitHub's documented include example: a matching include expands one cell.""" matrix = Matrix() matrix.add_variation("os", ["windows-latest", "ubuntu-latest"]) matrix.add_variation("node", ["14", "16"]) matrix.add_includes({"os": "windows-latest", "node": "16", "npm": "6"}) assert tuple(matrix.solve()) == ( {"os": "windows-latest", "node": "14"}, {"os": "windows-latest", "node": "16", "npm": "6"}, {"os": "ubuntu-latest", "node": "14"}, {"os": "ubuntu-latest", "node": "16"}, )
[docs] def test_solve_extended_configuration(): """GitHub's documented include example: a non-matching include appends a job.""" matrix = Matrix() matrix.add_variation("os", ["macos-latest", "windows-latest"]) matrix.add_variation("version", ["12", "14", "16"]) matrix.add_includes({"os": "windows-latest", "version": "17"}) assert tuple(matrix.solve()) == ( {"os": "macos-latest", "version": "12"}, {"os": "macos-latest", "version": "14"}, {"os": "macos-latest", "version": "16"}, {"os": "windows-latest", "version": "12"}, {"os": "windows-latest", "version": "14"}, {"os": "windows-latest", "version": "16"}, {"os": "windows-latest", "version": "17"}, )
[docs] def test_solve_empty_matrix(): """Includes alone define the jobs when no axis is declared.""" matrix = Matrix() matrix.add_includes( {"site": "production", "datacenter": "site-a"}, {"site": "staging", "datacenter": "site-b"}, ) assert tuple(matrix.solve()) == ( {"site": "production", "datacenter": "site-a"}, {"site": "staging", "datacenter": "site-b"}, )
[docs] @pytest.mark.parametrize( "excludes", list( permutations( ( # The order of these 3 includes directives can be shuffled as the # final result order is imposed by the base variations of the original # matrix. {"os": "macos-latest", "version": "12", "environment": "production"}, {"os": "windows-latest", "version": "16"}, ), 2, ) ), ) def test_solve_excludes(excludes): """Excludes remove matching cells, whatever order they are declared in.""" matrix = Matrix() matrix.add_variation("os", ["macos-latest", "windows-latest"]) matrix.add_variation("version", ["12", "14", "16"]) matrix.add_variation("environment", ["staging", "production"]) matrix.add_excludes(*excludes) assert tuple(matrix.solve()) == ( {"os": "macos-latest", "version": "12", "environment": "staging"}, # {"os": "macos-latest", "version": "12", "environment": "production"}, {"os": "macos-latest", "version": "14", "environment": "staging"}, {"os": "macos-latest", "version": "14", "environment": "production"}, {"os": "macos-latest", "version": "16", "environment": "staging"}, {"os": "macos-latest", "version": "16", "environment": "production"}, {"os": "windows-latest", "version": "12", "environment": "staging"}, {"os": "windows-latest", "version": "12", "environment": "production"}, {"os": "windows-latest", "version": "14", "environment": "staging"}, {"os": "windows-latest", "version": "14", "environment": "production"}, # {"os": "windows-latest", "version": "16", "environment": "staging"}, # {"os": "windows-latest", "version": "16", "environment": "production"}, )
[docs] @pytest.mark.parametrize( "excludes", list( permutations( ( # The order of these 2 excludes directives can be shuffled as the # final result order is imposed by the base variations of the original # matrix. {"os": "windows-latest", "version": "14"}, {"os": "linux-latest"}, ), 2, ) ), ) def test_solve_exclude_partial(excludes): """A partial exclude removes every cell sharing the named values.""" matrix = Matrix() matrix.add_variation("os", ["macos-latest", "windows-latest", "linux-latest"]) matrix.add_variation("version", ["14", "16"]) matrix.add_excludes(*excludes) assert tuple(matrix.solve()) == ( {"os": "macos-latest", "version": "14"}, {"os": "macos-latest", "version": "16"}, # {"os": "windows-latest", "version": "14"}, {"os": "windows-latest", "version": "16"}, # {"os": "linux-latest", "version": "14"}, # {"os": "linux-latest", "version": "16"}, )
[docs] def test_solve_exclude_include_priority(): """A partial include re-added after exclude is a single job, not a slice. The include `{os: linux-latest}` matches no surviving combination (adding it would overwrite the original `os`), so GitHub appends it verbatim as one new job. It does not fan back out across the excluded `version` axis: that would require the include to specify each `version` itself. """ matrix = Matrix() matrix.add_variation("os", ["macos-latest", "windows-latest", "linux-latest"]) matrix.add_variation("version", ["14", "16"]) matrix.add_includes({"os": "linux-latest"}) matrix.add_excludes({"os": "linux-latest"}) assert tuple(matrix.solve()) == ( {"os": "macos-latest", "version": "14"}, {"os": "macos-latest", "version": "16"}, {"os": "windows-latest", "version": "14"}, {"os": "windows-latest", "version": "16"}, {"os": "linux-latest"}, )
[docs] def test_solve_exclude_include_selectivity(): """A fully-qualified include survives an exclude that removes its siblings.""" matrix = Matrix() matrix.add_variation("os", ["macos-latest", "windows-latest", "linux-latest"]) matrix.add_variation("version", ["14", "16"]) matrix.add_includes({"os": "linux-latest", "version": "16", "node": "20"}) matrix.add_excludes({"os": "linux-latest"}) assert tuple(matrix.solve()) == ( {"os": "macos-latest", "version": "14"}, {"os": "macos-latest", "version": "16"}, {"os": "windows-latest", "version": "14"}, {"os": "windows-latest", "version": "16"}, # {"os": "linux-latest", "version": "14"}, {"os": "linux-latest", "version": "16", "node": "20"}, )
[docs] def test_strict_mode_unknown_exclude_key(): """Strict mode rejects an exclude naming an axis the matrix never declared.""" matrix = Matrix() matrix.add_variation("os", ["macos-latest", "windows-latest", "linux-latest"]) matrix.add_variation("version", ["3.14", "3.13", "3.12"]) matrix.add_includes() matrix.add_excludes( {"os": "linux-latest", "unknown_key": "random"}, {"version": "3.14"}, ) assert tuple(matrix.solve(strict=False)) == ( {"os": "macos-latest", "version": "3.13"}, {"os": "macos-latest", "version": "3.12"}, {"os": "windows-latest", "version": "3.13"}, {"os": "windows-latest", "version": "3.12"}, ) with pytest.raises(ValueError): tuple(matrix.solve(strict=True))
[docs] def test_pivot_basic(): """pivot() lays solved jobs out as a row-axis by col-axis grid.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26"]) matrix.add_variation("python-version", ["3.10", "3.14"]) matrix.add_includes({"state": "stable"}) cols, rows = matrix.pivot() assert cols == ("ubuntu-slim", "macos-26") assert rows == ( ("3.10", "stable", "stable"), ("3.14", "stable", "stable"), )
[docs] def test_pivot_excluded_cell_is_missing(): """An excluded combination renders the missing placeholder.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26"]) matrix.add_variation("python-version", ["3.10", "3.14"]) matrix.add_includes({"state": "stable"}) matrix.add_excludes({"os": "macos-26", "python-version": "3.14"}) cols, rows = matrix.pivot() assert cols == ("ubuntu-slim", "macos-26") assert rows == ( ("3.10", "stable", "stable"), ("3.14", "stable", "—"), )
[docs] def test_pivot_marks_unstable_row(): """A partial state include flags the matching row's cells unstable.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26"]) matrix.add_variation("python-version", ["3.14", "3.15"]) matrix.add_includes({"state": "stable"}) matrix.add_includes({"state": "unstable", "python-version": "3.15"}) _, rows = matrix.pivot() assert rows == ( ("3.14", "stable", "stable"), ("3.15", "unstable", "unstable"), )
[docs] def test_pivot_multi_job_cell_joins_states(): """Several jobs at one intersection join their distinct states.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim"]) matrix.add_variation("python-version", ["3.14"]) matrix.add_variation("click-version", ["stable", "main"]) matrix.add_includes({"state": "stable"}) matrix.add_includes({"state": "unstable", "click-version": "main"}) cols, rows = matrix.pivot() assert cols == ("ubuntu-slim",) assert rows == (("3.14", "stable, unstable"),)
[docs] def test_pivot_counts_tallies_the_jobs_behind_each_cell(): """A cell collapsing several jobs counts them, per distinct state.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim"]) matrix.add_variation("python-version", ["3.14"]) matrix.add_variation("click-version", ["stable", "main", "released"]) matrix.add_includes({"state": "stable"}) matrix.add_includes({"state": "unstable", "click-version": "main"}) assert matrix.pivot_counts() == { ("3.14", "ubuntu-slim"): Counter({"stable": 2, "unstable": 1}) }
[docs] def test_pivot_counts_omits_an_empty_intersection(): """An excluded combination is absent, not present with an empty counter.""" matrix = Matrix() matrix.add_variation("os", ["ubuntu-slim", "macos-26"]) matrix.add_variation("python-version", ["3.10", "3.14"]) matrix.add_includes({"state": "stable"}) matrix.add_excludes({"os": "macos-26", "python-version": "3.14"}) tallies = matrix.pivot_counts() assert ("3.14", "macos-26") not in tallies assert set(tallies) == { ("3.10", "ubuntu-slim"), ("3.10", "macos-26"), ("3.14", "ubuntu-slim"), } assert all(tally == Counter({"stable": 1}) for tally in tallies.values())
[docs] def test_pivot_custom_axes_and_missing_marker(): """pivot() accepts arbitrary axes, cell key and missing placeholder.""" matrix = Matrix() matrix.add_variation("fruit", ["apple", "pear"]) matrix.add_variation("city", ["paris", "tokyo"]) matrix.add_includes({"taste": "sweet"}) matrix.add_excludes({"fruit": "pear", "city": "tokyo"}) cols, rows = matrix.pivot( row_axis="fruit", col_axis="city", cell_key="taste", missing="n/a" ) assert cols == ("paris", "tokyo") assert rows == ( ("apple", "sweet", "sweet"), ("pear", "sweet", "n/a"), )