"""
Matrix combination generator for test case construction.
This module implements a matrix-based test case generator similar to the
matrix strategy of GitHub Actions. It produces the cartesian product of
parameter values and supports additional inclusion and exclusion rules to
fine-tune the resulting cases.
The module exposes the following public component:
* :class:`MatrixGenerator` - Generate all matrix combinations with optional
include and exclude rules.
Example::
>>> from hbutils.testing.generator.matrix import MatrixGenerator
>>> gen = MatrixGenerator(
... {'a': [1, 2], 'b': ['x', 'y']},
... includes=[{'a': 3, 'b': 'z'}],
... excludes=[{'a': 1, 'b': 'x'}]
... )
>>> for case in gen.cases():
... print(case)
{'a': 1, 'b': 'y'}
{'a': 2, 'b': 'x'}
{'a': 2, 'b': 'y'}
{'a': 3, 'b': 'z'}
.. note::
Include and exclude items are normalized to tuples internally to align with
the behavior of the base generator.
"""
from typing import Iterator, Mapping, Optional, List, Any, Tuple
from .base import BaseGenerator, _single_dict_process, _single_to_tuple, _check_keys
[docs]
class MatrixGenerator(BaseGenerator):
"""
Full matrix model, all the cases in this matrix will be iterated.
This generator creates a cartesian product of all provided parameter values,
with optional inclusions and exclusions to customize the generated test cases.
"""
[docs]
def __init__(self, values: Mapping[str, Any],
names: Optional[List[str]] = None,
includes: Optional[List[Mapping[str, Any]]] = None,
excludes: Optional[List[Mapping[str, Any]]] = None) -> None:
"""
Constructor of the :class:`MatrixGenerator` class.
It is similar to GitHub Action's matrix strategy, generating all combinations
of the provided values with support for custom inclusions and exclusions.
Include and exclude mappings are normalized so that every value is a tuple.
:param values: Matrix values, such as ``{'a': [2, 3], 'b': ['b', 'c']}``.
:type values: Mapping[str, Any]
:param names: Names of the given generator, default is ``None`` which means use the sorted \
key set of the values.
:type names: Optional[List[str]]
:param includes: Include items, such as ``[{'a': 4, 'b': 'b'}]``, \
default is ``None`` which means no extra inclusions.
:type includes: Optional[List[Mapping[str, Any]]]
:param excludes: Exclude Items, such as ``[{'a': 2, 'b': 'c'}]``, \
default is ``None`` which means no extra exclusions.
:type excludes: Optional[List[Mapping[str, Any]]]
:raises KeyError: If include or exclude items contain keys not in ``values``.
Example::
>>> gen = MatrixGenerator(
... {'a': [1, 2], 'b': ['x', 'y']},
... includes=[{'a': 3, 'b': 'z'}],
... excludes=[{'a': 1, 'b': 'x'}]
... )
"""
BaseGenerator.__init__(self, values, names)
_name_set = set(self.names)
self.__includes = [_single_dict_process(inc) for inc in (includes or [])]
for includes in self.__includes:
_check_keys(includes, _name_set)
self.__excludes = [_single_dict_process(exc) for exc in (excludes or [])]
for excludes in self.__excludes:
_check_keys(excludes, _name_set)
@property
def includes(self) -> List[Mapping[str, Tuple[Any, ...]]]:
"""
Get the include items.
Include items are additional parameter combinations that will be added
to the generated cases, even if they don't fit the original matrix values.
Each value in the mapping is normalized to a tuple of values.
:return: List of include item mappings.
:rtype: List[Mapping[str, Tuple[Any, ...]]]
"""
return self.__includes
@property
def excludes(self) -> List[Mapping[str, Tuple[Any, ...]]]:
"""
Get the exclude items.
Exclude items are parameter combinations that will be filtered out
from the generated cases. A case is excluded if it matches all
key-value pairs in any exclude item. Each value in the mapping is
normalized to a tuple of values.
:return: List of exclude item mappings.
:rtype: List[Mapping[str, Tuple[Any, ...]]]
"""
return self.__excludes
[docs]
def cases(self) -> Iterator[Mapping[str, Any]]:
"""
Get the cases in this matrix.
Generates all possible combinations of the matrix values, applying
exclusions and inclusions as specified. The method performs a cartesian
product of all parameter values, then filters based on exclude rules,
and finally adds any specified include cases.
:return: Iterator yielding dictionaries representing each test case.
:rtype: Iterator[Mapping[str, Any]]
Examples::
>>> from hbutils.testing import MatrixGenerator
>>> for p in MatrixGenerator(
... {'a': [1, 2, 3], 'b': ['a', 'b'], 'r': [3, 4, 5]},
... includes=[{'a': 4, 'r': 7}],
... excludes=[{'a': 1, 'r': 3}, {'a': 3, 'b': 'b'}, {'a': 4, 'b': 'a', 'r': 7}]
... ).cases():
... print(p)
{'a': 1, 'b': 'a', 'r': 4}
{'a': 1, 'b': 'a', 'r': 5}
{'a': 1, 'b': 'b', 'r': 4}
{'a': 1, 'b': 'b', 'r': 5}
{'a': 2, 'b': 'a', 'r': 3}
{'a': 2, 'b': 'a', 'r': 4}
{'a': 2, 'b': 'a', 'r': 5}
{'a': 2, 'b': 'b', 'r': 3}
{'a': 2, 'b': 'b', 'r': 4}
{'a': 2, 'b': 'b', 'r': 5}
{'a': 3, 'b': 'a', 'r': 3}
{'a': 3, 'b': 'a', 'r': 4}
{'a': 3, 'b': 'a', 'r': 5}
{'a': 4, 'b': 'b', 'r': 7}
"""
n = len(self.names)
def _check_single_exclude(dict_value: Mapping[str, Any], exclude: Mapping[str, Tuple[Any, ...]]) -> bool:
"""
Check if a single case matches an exclude pattern.
:param dict_value: The case dictionary to check.
:type dict_value: Mapping[str, Any]
:param exclude: The exclude pattern to match against.
:type exclude: Mapping[str, Tuple[Any, ...]]
:return: True if the case matches the exclude pattern, False otherwise.
:rtype: bool
"""
for key, value in exclude.items():
if key not in dict_value or dict_value[key] not in value:
return False
return True
def _check_exclude(dict_value: Mapping[str, Any], excludes: List[Mapping[str, Tuple[Any, ...]]]) -> bool:
"""
Check if a case should be excluded based on all exclude patterns.
:param dict_value: The case dictionary to check.
:type dict_value: Mapping[str, Any]
:param excludes: List of exclude patterns to check against.
:type excludes: List[Mapping[str, Tuple[Any, ...]]]
:return: True if the case should be excluded, False otherwise.
:rtype: bool
"""
for exclude in excludes:
if _check_single_exclude(dict_value, exclude):
return True
return False
def _matrix_recursion(depth: int, dict_value: Mapping[str, Any],
values: Mapping[str, Tuple[Any, ...]],
excludes: List[Mapping[str, Tuple[Any, ...]]]) -> Iterator[Mapping[str, Any]]:
"""
Recursively generate matrix combinations.
This function performs a depth-first traversal to generate all combinations
of parameter values, checking exclusions at each level.
:param depth: Current recursion depth (parameter index).
:type depth: int
:param dict_value: Current accumulated parameter values.
:type dict_value: Mapping[str, Any]
:param values: Available values for each parameter.
:type values: Mapping[str, Tuple[Any, ...]]
:param excludes: Exclude patterns to apply.
:type excludes: List[Mapping[str, Tuple[Any, ...]]]
:return: Iterator yielding valid case dictionaries.
:rtype: Iterator[Mapping[str, Any]]
"""
if _check_exclude(dict_value, excludes):
return
if depth < n:
name = self.names[depth]
for curitem in values[name]:
yield from _matrix_recursion(depth + 1, {**dict_value, name: curitem}, values, excludes)
else:
yield dict_value
value_items = [self.values, *({
name: _single_to_tuple(include[name]) if name in include else self.values[name]
for name in self.names
} for include in self.includes)]
local_excludes = [*self.excludes]
for vis in value_items:
yield from _matrix_recursion(0, {}, vis, local_excludes)
local_excludes.append(vis)