diff --git a/fmf/base.py b/fmf/base.py index 31959bb..cfce46a 100644 --- a/fmf/base.py +++ b/fmf/base.py @@ -495,7 +495,7 @@ def adjust( context, key='adjust', undecided='skip', - case_sensitive=True, + case_sensitive: Optional[bool] = None, decision_callback: Optional[AdjustCallback] = None, additional_rules=None, additional_rules_callback: Optional[ApplyRulesCallback] = None): @@ -513,10 +513,6 @@ class describing the environment context. By default, the key skipped. In order to raise the fmf.context.CannotDecide exception in such cases use undecided='raise'. - Optional 'case_sensitive' parameter can be used to specify - if the context dimension values should be case-sensitive when - matching the rules. By default, values are case-sensitive. - Optional 'decision_callback' callback would be called for every adjust rule inspected, with three arguments: current fmf node, current adjust rule, and whether it was applied or not. @@ -538,6 +534,10 @@ class describing the environment context. By default, the key raise utils.GeneralError( "Invalid adjust context: '{}'.".format(type(context).__name__)) + # TODO: Remove this in next release + if case_sensitive is not None: + context._context_dimensions._default_dimension_cls.case_sensitive = case_sensitive + # Adjust rules should be a dictionary or a list of dictionaries try: rules = self.data[key] @@ -559,8 +559,6 @@ class describing the environment context. By default, the key elif isinstance(additional_rules, dict): additional_rules = [additional_rules] - context.case_sensitive = case_sensitive - def apply_rules(rule_set): # 'continue' has to affect only its rule_set for rule in rule_set: diff --git a/fmf/context.py b/fmf/context.py index 29f86c8..8974d83 100644 --- a/fmf/context.py +++ b/fmf/context.py @@ -16,7 +16,16 @@ See https://fmf.readthedocs.io/en/latest/modules.html#fmf.Tree.adjust """ +import functools import re +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import ClassVar, Generic, Optional, TypeVar + +from fmf._compat.typing import TypeAlias + +T = TypeVar("T") class CannotDecide(Exception): @@ -31,6 +40,306 @@ class InvalidContext(Exception): pass +OperatorFunc: TypeAlias = Callable[[str], bool] + + +@dataclass(frozen=True) +class Operators: + """ + Decorator for defining a comparison operator. + """ + + #: Registrar of defined operators and the functions associated with them. Negated + #: operators use the same function, but are marked to be negated in the second term + #: of the tuple. + registrar: dict[str, tuple[str, bool]] = field(default_factory=dict) + + def add(self, + operator: str, + negated_operator: Optional[str] = None, + ) -> Callable[[OperatorFunc], OperatorFunc]: + def decorator(func: OperatorFunc) -> OperatorFunc: + if operator in self.registrar: + raise ValueError(f"Operator '{operator}' already defined") + self.registrar[operator] = (func.__name__, False) + if negated_operator: + self.registrar[negated_operator] = (func.__name__, True) + return func + + assert operator != negated_operator + return decorator + + def execute(self, operator: str, inst: "ContextDimension[T]", other: str) -> bool: + operator_func, negate = self.registrar[operator] + func: OperatorFunc = getattr(inst, operator_func) + if negate: + return not func(other) + return func(other) + + +@dataclass(frozen=True) +class ContextDimension(ABC, Generic[T]): + """ + Representation of a context dimension with both name and value. + + This defines the operator rules and processing of the raw string values. + + A consumer should subclass this and initialize :py:attr:`_registrar` to define + their own subset of context dimensions that they process. By default (if this is + not subclassed and nothing is registered) the dimension values are treated as + :py:class:`ContextValue`. + + .. code-block:: python + + class TmtContextDimension(fmf.context.ContextDimension): + _registrar = {} + + class TmtContext(fmf.context.Context): + _context_dimensions = TmtContextDimension + + class DistroContextDimension(TmtContextDimension[DistroAlias]): + _dimension_name = "distro" + + @classmethod + @abstractmethod + def _make_value(cls, raw_value: str) -> DistroAlias: + ... + + def operate_value(self, operator: str, other_value: DistroAlias) -> bool: + ... + """ + + #: Collection of comparison operators defined + operators: ClassVar[Operators] = Operators() + + #: Collection of known :py:class:`ContextDimension`. The consumer should + #: initialize + _registrar: ClassVar[dict[str, type["ContextDimension"]]] + + #: Default :py:class:`ContextDimension` class used in :py:func:`create_default` + _default_dimension_cls: ClassVar[type["DefaultContextDimension"]] + + #: Static dimension name. Must be defined when subclassing a specific + #: :py:class:`ContextDimension` + _dimension_name: ClassVar[str] + + #: The raw value given by the user + raw_value: str + + @property + def name(self) -> str: + """ + The final context dimension name + """ + return self._dimension_name + + @functools.cached_property + def value(self) -> T: + """ + The dimension's processed value + """ + return self._make_value(self.raw_value) + + @classmethod + @abstractmethod + def _make_value(cls, raw_value: str) -> T: + """ + Convert a ``raw_value`` string into an actual ``T`` type + """ + raise NotImplementedError + + @classmethod + def __init_subclass__(cls) -> None: + # Do nothing if this is a dynamic ContextDimension + if not hasattr(cls, "_dimension_name"): + return + cls._registrar[cls._dimension_name] = cls + + @classmethod + def create(cls, dimension_name: str, raw_value: str) -> "ContextDimension": + """ + Main constructor + """ + # Safely get the registrar if one was initialized + registrar = getattr(cls, "_registrar", {}) + if dimension_type := registrar.get(dimension_name): + return dimension_type(raw_value) + return cls.create_default(raw_value, dimension_name=dimension_name) + + @classmethod + def create_default(cls, raw_value: str, *, dimension_name: str) -> "ContextDimension": + """ + The default :py:class:`ContextDimension` if none were found in the :py:attr:`_registrar`. + """ + return cls._default_dimension_cls(raw_value, dimension_name=dimension_name) + + def operate(self, operator: str, other: str) -> bool: + if operator not in self.operators.registrar: + raise NotImplementedError + return self.operators.execute(operator, self, other) + + @operators.add("==", "!=") + @abstractmethod + def _op_eq(self, other: str) -> bool: + raise NotImplementedError + + # TODO: Check how to mimic functools.total_ordering logic + @operators.add("<") + @abstractmethod + def _op_less(self, other: str) -> bool: + raise NotImplementedError + + @operators.add("<=") + @abstractmethod + def _op_less_or_equal(self, other: str) -> bool: + raise NotImplementedError + + @operators.add(">") + @abstractmethod + def _op_greater(self, other: str) -> bool: + raise NotImplementedError + + @operators.add(">=") + @abstractmethod + def _op_greater_or_equal(self, other: str) -> bool: + raise NotImplementedError + + # TODO: Default to non-minor operators + @operators.add("~=", "~!=") + @abstractmethod + def _op_minor_eq(self, other: str) -> bool: + raise NotImplementedError + + @operators.add("~<") + @abstractmethod + def _op_minor_less(self, other: str) -> bool: + raise NotImplementedError + + @operators.add("~<=") + @abstractmethod + def _op_minor_less_or_equal(self, other: str) -> bool: + raise NotImplementedError + + @operators.add("~>") + @abstractmethod + def _op_minor_greater(self, other: str) -> bool: + raise NotImplementedError + + @operators.add("~>=") + @abstractmethod + def _op_minor_greater_or_equal(self, other: str) -> bool: + raise NotImplementedError + + @operators.add("~", "!~") + def _op_match(self, other: str) -> bool: + return re.search(other, self.raw_value) is not None + + +@dataclass(frozen=True) +class DefaultContextDimension(ContextDimension["ContextValue"]): + """ + Generic :py:class:`ContextDimension` with variable dimension key. + + The default implementation treats the context dimensions as :py:class:`ContextValue` + satisfying the comparison logic in :ref:`context` section. + + This is used in :py:func:`ContextDimension.create_default` via + :py:attr:`ContextDimension._default_dimension_cls`. + """ + + #: Whether the context dimensions are compared in a case sensitive way + case_sensitive: ClassVar[bool] = True + + #: Dynamic dimension name + dimension_name: str = field() + + @property + def name(self) -> str: + return self.dimension_name + + @classmethod + def _make_value(cls, raw_value: str) -> "ContextValue": + return ContextValue(raw_value) + + def _op_eq(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + ordered=False, + case_sensitive=self.case_sensitive, + ) == 0 + + def _op_less(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + ordered=True, + case_sensitive=self.case_sensitive, + ) < 0 + + def _op_less_or_equal(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + ordered=True, + case_sensitive=self.case_sensitive, + ) <= 0 + + def _op_greater(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + ordered=True, + case_sensitive=self.case_sensitive, + ) > 0 + + def _op_greater_or_equal(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + ordered=True, + case_sensitive=self.case_sensitive, + ) >= 0 + + def _op_minor_eq(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + minor_mode=True, + ordered=False, + case_sensitive=self.case_sensitive, + ) == 0 + + def _op_minor_less(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + minor_mode=True, + ordered=True, + case_sensitive=self.case_sensitive, + ) < 0 + + def _op_minor_less_or_equal(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + minor_mode=True, + ordered=True, + case_sensitive=self.case_sensitive, + ) <= 0 + + def _op_minor_greater(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + minor_mode=True, + ordered=True, + case_sensitive=self.case_sensitive, + ) > 0 + + def _op_minor_greater_or_equal(self, other: str) -> bool: + return self.value.version_cmp( + self._make_value(other), + minor_mode=True, + ordered=True, + case_sensitive=self.case_sensitive, + ) >= 0 + + +ContextDimension._default_dimension_cls = DefaultContextDimension + + class ContextValue: """ Value for dimension @@ -219,7 +528,7 @@ class Context: """ Represents https://fmf.readthedocs.io/en/latest/context.html """ - # Operators' definitions + _context_dimensions: ClassVar[type[ContextDimension]] = ContextDimension def _op_defined(self, dimension_name, values): """ @@ -233,159 +542,7 @@ def _op_not_defined(self, dimension_name, values): """ return dimension_name not in self._dimensions - def _op_eq(self, dimension_name, values): - """ - '=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, ordered=False, case_sensitive=self.case_sensitive) == 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_not_eq(self, dimension_name, values): - """ - '!=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, ordered=False, case_sensitive=self.case_sensitive) != 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_match(self, dimension_name, values): - """ - '~' operator, regular expression matches - """ - - def comparator(dimension_value, it_val): - return re.search(it_val.raw, dimension_value.raw) is not None - - return self._op_core(dimension_name, values, comparator) - - def _op_not_match(self, dimension_name, values): - """ - '~' operator, regular expression does not match - """ - - def comparator(dimension_value, it_val): - return re.search(it_val.raw, dimension_value.raw) is None - - return self._op_core(dimension_name, values, comparator) - - def _op_minor_eq(self, dimension_name, values): - """ - '~=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, minor_mode=True, ordered=False, case_sensitive=self.case_sensitive) == 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_minor_not_eq(self, dimension_name, values): - """ - '~!=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, minor_mode=True, ordered=False, case_sensitive=self.case_sensitive) != 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_minor_less_or_eq(self, dimension_name, values): - """ - '~<=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, minor_mode=True, ordered=True, case_sensitive=self.case_sensitive) <= 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_minor_less(self, dimension_name, values): - """ - '~<' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, minor_mode=True, ordered=True, case_sensitive=self.case_sensitive) < 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_less(self, dimension_name, values): - """ - '<' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, ordered=True, case_sensitive=self.case_sensitive) < 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_less_or_equal(self, dimension_name, values): - """ - '<=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, ordered=True, case_sensitive=self.case_sensitive) <= 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_greater_or_equal(self, dimension_name, values): - """ - '>=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, ordered=True, case_sensitive=self.case_sensitive) >= 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_minor_greater_or_equal(self, dimension_name, values): - """ - '~>=' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, minor_mode=True, ordered=True, case_sensitive=self.case_sensitive) >= 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_greater(self, dimension_name, values): - """ - '>' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, ordered=True, case_sensitive=self.case_sensitive) > 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_minor_greater(self, dimension_name, values): - """ - '~>' operator - """ - - def comparator(dimension_value, it_val): - return dimension_value.version_cmp( - it_val, minor_mode=True, ordered=True, case_sensitive=self.case_sensitive) > 0 - - return self._op_core(dimension_name, values, comparator) - - def _op_core(self, dimension_name, values, comparator): + def _op_core(self, dimension_name, values, operator): """ Evaluate value from dimension vs target values combination @@ -397,9 +554,10 @@ def _op_core(self, dimension_name, values, comparator): try: decided = False for dimension_value in self._dimensions[dimension_name]: + assert isinstance(dimension_value, ContextDimension) for it_val in values: try: - if comparator(dimension_value, it_val): + if dimension_value.operate(operator, it_val): return True else: decided = True @@ -413,34 +571,25 @@ def _op_core(self, dimension_name, values, comparator): raise CannotDecide( "Dimension {0} is not defined.".format(dimension_name)) + # TODO: clean this up, not really necessary anymore + # Can't use the ContextDimension, but maybe we can use similar decorators. operator_map = { "is defined": _op_defined, "is not defined": _op_not_defined, - "<": _op_less, - "~<": _op_minor_less, - "<=": _op_less_or_equal, - "~<=": _op_minor_less_or_eq, - "==": _op_eq, - "~=": _op_minor_eq, - "!=": _op_not_eq, - "~!=": _op_minor_not_eq, - ">=": _op_greater_or_equal, - "~>=": _op_minor_greater_or_equal, - ">": _op_greater, - "~>": _op_minor_greater, - "~": _op_match, - "!~": _op_not_match, } - # Triple expression: dimension operator values - # [^=].* is necessary as .+ matches '= something' - re_expression_triple = re.compile( - r"([\w-]+)" - + r"\s*(" - + r"|".join( - [key for key in operator_map if key not in ["is defined", "is not defined"]]) - + r")\s*" - + r"([^=].*)") + @classmethod + @functools.cache + def re_expression_triple(cls) -> re.Pattern[str]: + # Triple expression: dimension operator values + # [^=].* is necessary as .+ matches '= something' + return re.compile( + r"([\w-]+)" + + r"\s*(" + + r"|".join( + [re.escape(op) for op in cls._context_dimensions.operators.registrar]) + + r")\s*" + + r"([^=].*)") # Double expression: dimension operator re_expression_double = re.compile( r"([\w-]+)" + r"\s*(" + r"|".join(["is defined", "is not defined"]) + r")" @@ -464,38 +613,30 @@ def __init__(self, *args, **kwargs): :raises InvalidContext """ self._dimensions = {} - self.case_sensitive = True # Initialized with rule if args: if len(args) != 1: raise InvalidContext() - definition = Context.parse_rule(args[0]) + definition = self.parse_rule(args[0]) # No ORs and at least one expression in AND if len(definition) != 1 or not definition[0]: raise InvalidContext() for dim, op, values in definition[0]: if op != "==": raise InvalidContext() - self._dimensions[dim] = set(values) + self._dimensions[dim] = set( + [self._context_dimensions.create(dim, val) for val in values]) # Initialized with dimension=value(s) for dimension_name, values in kwargs.items(): if not isinstance(values, list): values = [values] self._dimensions[dimension_name] = set( - [self.parse_value(val) for val in values] + [self._context_dimensions.create(dimension_name, val) for val in values] ) - @property - def case_sensitive(self) -> bool: - return self._case_sensitive - - @case_sensitive.setter - def case_sensitive(self, value: bool): - self._case_sensitive = value - - @staticmethod - def parse_rule(rule): + @classmethod + def parse_rule(cls, rule): """ Parses rule into expressions @@ -521,31 +662,19 @@ def parse_rule(rule): # Change '=' to '==' rule = re.sub(r"(?)=(?!=)", "==", rule) - rule_parts = Context.split_rule_to_groups(rule) + rule_parts = cls.split_rule_to_groups(rule) for and_group in rule_parts: parsed_and_group = [] for part in and_group: - dimension, operator, raw_values = Context.split_expression( + dimension, operator, values = cls.split_expression( part) - if raw_values is not None: - values = [ - Context.parse_value(value) for value in raw_values] - else: - values = None parsed_and_group.append((dimension, operator, values)) if parsed_and_group: parsed_rule.append(parsed_and_group) return parsed_rule - @staticmethod - def parse_value(value): - """ - Single place to convert to ContextValue - """ - return ContextValue(str(value)) - - @staticmethod - def split_rule_to_groups(rule): + @classmethod + def split_rule_to_groups(cls, rule): """ Split rule into nested lists, no real parsing @@ -556,11 +685,11 @@ def split_rule_to_groups(rule): :raises InvalidRule: Syntax error in the rule """ rule_parts = [] - for or_group in Context.re_or_split.split(rule): + for or_group in cls.re_or_split.split(rule): if not or_group: raise InvalidRule("Empty OR expression in {}.".format(rule)) and_group = [] - for part in Context.re_and_split.split(or_group): + for part in cls.re_and_split.split(or_group): part_stripped = part.strip() if not part_stripped: raise InvalidRule( @@ -569,8 +698,8 @@ def split_rule_to_groups(rule): rule_parts.append(and_group) return rule_parts - @staticmethod - def split_expression(expression): + @classmethod + def split_expression(cls, expression): """ Split expression to dimension name, operator and values @@ -584,7 +713,7 @@ def split_expression(expression): :rtype: tuple(str|None, str|bool, list|None) """ # true/false - match = Context.re_boolean.match(expression) + match = cls.re_boolean.match(expression) if match: # convert to bool and return expression tuple if match.group(1)[0].lower() == 't': @@ -592,13 +721,13 @@ def split_expression(expression): else: return (None, False, None) # Triple expressions - match = Context.re_expression_triple.match(expression) + match = cls.re_expression_triple().match(expression) if match: dimension, operator, raw_values = match.groups() return (dimension, operator, [ val.strip() for val in raw_values.split(",")]) # Double expressions - match = Context.re_expression_double.match(expression) + match = cls.re_expression_double.match(expression) if match: return (match.group(1), match.group(2), None) raise InvalidRule("Cannot parse expression '{}'.".format(expression)) @@ -677,4 +806,8 @@ def evaluate(self, expression): dimension_name, operator, values = expression if isinstance(operator, bool): return operator - return self.operator_map[operator](self, dimension_name, values) + if operator in self.operator_map: + # TODO: clean this up, not really necessary anymore + return self.operator_map[operator](self, dimension_name, values) + else: + return self._op_core(dimension_name, values, operator) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..e9388a0 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,41 @@ +import pytest + +from fmf.context import Context, ContextDimension, DefaultContextDimension + + +@pytest.fixture(scope="function") +def custom_context_cls() -> type[Context]: + + class TestDefaultContextDimension(DefaultContextDimension): + case_sensitive = True + + class TestContextDimension(ContextDimension): + _registrar = {} + _default_dimension_cls = TestDefaultContextDimension + + class TestContext(Context): + _context_dimensions = TestContextDimension + + return TestContext + + +@pytest.fixture(scope="function") +def default_context_cls() -> type[Context]: + return Context + + +@pytest.fixture( + scope="function", + params=["custom_context", "default_context"], + ) +def context_cls( + request: pytest.FixtureRequest, + custom_context_cls: type[Context], + default_context_cls: type[Context]) -> type[Context]: + # Note: it is inefficient to request both context classes, but I could not find + # a better way around it, maybe someone in the future can find one. + if request.param == "custom_context": + return custom_context_cls + elif request.param == "default_context": + return default_context_cls + raise NotImplementedError(f"Unknown ctx_id: {request.param}") diff --git a/tests/unit/test_adjust.py b/tests/unit/test_adjust.py index d7b4b3d..6e504cf 100644 --- a/tests/unit/test_adjust.py +++ b/tests/unit/test_adjust.py @@ -10,21 +10,21 @@ @pytest.fixture -def fedora(): +def fedora(context_cls: type[Context]): """ Fedora 33 on x86_64 and ppc64 """ - return Context(distro='fedora-33', arch=['x86_64', 'ppc64']) + return context_cls(distro='fedora-33', arch=['x86_64', 'ppc64']) @pytest.fixture -def centos(): +def centos(context_cls: type[Context]): """ CentOS 8.4 """ - return Context(distro='centos-8.4') + return context_cls(distro='centos-8.4') @pytest.fixture @@ -304,20 +304,35 @@ def test_adjust_callback(self, mini, fedora, centos): mock_callback.assert_any_call(mini, add_rule, True) assert mock_callback.call_count == 2 - def test_case_sensitive(self, mini, centos): + @pytest.mark.parametrize("case_sensitive", [True, False, pytest.param(None, id="default")]) + def test_case_sensitive(self, mini, custom_context_cls, case_sensitive): """ - Make sure the adjust rules are case-sensitive by default + Make sure the adjust rules follow case-sensitive setting """ + if case_sensitive is not None: + # Set the value for `DefaultContextDimension.case_sensitive` + custom_context_cls._context_dimensions._default_dimension_cls.case_sensitive = ( + case_sensitive) + + centos = custom_context_cls(distro='centos-8.4') + mini.data['adjust'] = dict(when='distro = CentOS', enabled=False) mini.adjust(centos) - assert mini.get('enabled') is True + if case_sensitive or case_sensitive is None: + assert mini.get('enabled') is True + else: + assert mini.get('enabled') is False - def test_case_insensitive(self, mini, centos): - """ - Make sure the adjust rules are case-insensitive when requested - """ + # TODO: Remove this in next release + @pytest.mark.parametrize("case_sensitive", [True, False]) + def test_case_sensitive_workaround(self, mini, context_cls, case_sensitive): + + centos = context_cls(distro='centos-8.4') mini.data['adjust'] = dict(when='distro = CentOS', enabled=False) - mini.adjust(centos, case_sensitive=False) - assert mini.get('enabled') is False + mini.adjust(centos, case_sensitive=case_sensitive) + if case_sensitive: + assert mini.get('enabled') is True + else: + assert mini.get('enabled') is False diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index b26e375..6b2dba9 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -5,8 +5,8 @@ @pytest.fixture -def env_centos(): - return Context( +def env_centos(context_cls: type[Context]): + return context_cls( arch="x86_64", distro="centos-8.4.0", component=["bash-5.0.17-1.fc32", "python3-3.8.5-5.fc32"], @@ -66,13 +66,13 @@ def test_multi_condition(self, env_centos): "distro = fedora and component >= bash-5.0 or " "distro = rhel and component >= bash-4.9") - def test_minor_comparison_mode(self): + def test_minor_comparison_mode(self, context_cls: type[Context]): """ How it minor comparison should work """ - centos = Context(distro="centos-7.3.0") - centos6 = Context(distro="centos-6.9.0") + centos = context_cls(distro="centos-7.3.0") + centos6 = context_cls(distro="centos-6.9.0") # Simple version compare is not enough # Think about feature added in centos-7.4.0 and centos-6.9.0 @@ -119,31 +119,31 @@ def test_minor_comparison_mode(self): "distro ~>= centos-7.4.0 and distro ~>= centos-6.9.0" ) - def test_true_false(self): + def test_true_false(self, context_cls: type[Context]): """ true/false can be used in rule """ - empty = Context() + empty = context_cls() assert empty.matches('true') assert empty.matches(True) assert not empty.matches('false') assert not empty.matches(False) - fedora = Context(distro='fedora-rawhide') + fedora = context_cls(distro='fedora-rawhide') # e.g. ad-hoc disabling of rule assert not fedora.matches('false and distro == fedora') # or enable rule (can be done also by removing when key) assert fedora.matches('true or distro == centos-stream') - def test_right_side_defines_precision(self): + def test_right_side_defines_precision(self, context_cls: type[Context]): """ Right side defines how many version parts need to match """ - bar_830 = Context(dimension="bar-8.3.0") - bar_ = Context(dimension="bar") # so essentially bar-0.0.0 + bar_830 = context_cls(dimension="bar-8.3.0") + bar_ = context_cls(dimension="bar") # so essentially bar-0.0.0 # these are equal for value in "bar bar-8 bar-8.3 bar-8.3.0".split(): @@ -186,14 +186,14 @@ def test_right_side_defines_precision(self): with pytest.raises(CannotDecide): bar_.matches("dimension {0} {1}".format(op, value)) - def test_right_side_defines_precision_tilda(self): + def test_right_side_defines_precision_tilda(self, context_cls: type[Context]): """ Right side defines how many version parts need to match (~ operations) """ - bar_830 = Context(dimension="bar-8.3.0") - bar_ = Context(dimension="bar") # missing major - bar_8 = Context(dimension="bar-8") # so essentially bar-8.0.0 + bar_830 = context_cls(dimension="bar-8.3.0") + bar_ = context_cls(dimension="bar") # missing major + bar_8 = context_cls(dimension="bar-8") # so essentially bar-8.0.0 # these are equal for value in "bar bar-8 bar-8.3 bar-8.3.0".split(): @@ -241,12 +241,12 @@ def test_right_side_defines_precision_tilda(self): with pytest.raises(CannotDecide): bar_8.matches("dimension {0} {1}".format(op, value)) - def test_module_streams(self): + def test_module_streams(self, context_cls: type[Context]): """ - How you can use Context for modules + How you can use context_cls for modules """ - perl = Context("module = perl:5.28") + perl = context_cls("module = perl:5.28") assert perl.matches("module >= perl:5") assert not perl.matches("module > perl:5") @@ -262,14 +262,14 @@ def test_module_streams(self): # e.g feature in 5.28+ but dropped in perl6 assert perl.matches("module ~>= perl:5.28") with pytest.raises(CannotDecide): - Context("module = perl:6.28").matches("module ~>= perl:5.28") + context_cls("module = perl:6.28").matches("module ~>= perl:5.28") - def test_comma(self): + def test_comma(self, context_cls: type[Context]): """ Comma is sugar for OR """ - con = Context(single="foo", multi=["first", "second"]) + con = context_cls(single="foo", multi=["first", "second"]) # First as longer line, then using comma assert con.matches("single == foo or single == bar") assert con.matches("single == foo, bar") @@ -290,52 +290,63 @@ def test_comma(self): assert con.matches("multi != first or multi != second") # More real-life example - distro = Context(distro="centos-stream-8") + distro = context_cls(distro="centos-stream-8") assert distro.matches("distro < centos-stream-9, fedora-34") assert not distro.matches("distro < fedora-34, centos-stream-8") - def test_case_insensitive(self): + @pytest.mark.parametrize("case_sensitive", [True, False, pytest.param(None, id="default")]) + def test_case_sensitive(self, custom_context_cls: type[Context], case_sensitive): """ Test for case-insensitive matching """ + if case_sensitive is not None: + # Set the value for `DefaultContextDimension.case_sensitive` + custom_context_cls._context_dimensions._default_dimension_cls.case_sensitive = ( + case_sensitive) - python = Context(component="python3-3.8.5-5.fc32") - python.case_sensitive = False + python = custom_context_cls(component="python3-3.8.5-5.fc32") assert python.matches("component == python3") assert not python.matches("component == invalid") - assert python.matches("component == PYTHON3,INVALID") - assert python.matches("component == Python3") - assert python.matches("component == PyTHon3-3.8.5-5.FC32") assert python.matches("component > python3-3.7") - assert python.matches("component < PYTHON3-3.9") - - def test_regular_expression_matching(self): + if case_sensitive or case_sensitive is None: + assert not python.matches("component == PYTHON3,INVALID") + assert not python.matches("component == Python3") + assert not python.matches("component == PyTHon3-3.8.5-5.FC32") + with pytest.raises(CannotDecide): + python.matches("component < PYTHON3-3.9") + else: + assert python.matches("component == PYTHON3,INVALID") + assert python.matches("component == Python3") + assert python.matches("component == PyTHon3-3.8.5-5.FC32") + assert python.matches("component < PYTHON3-3.9") + + def test_regular_expression_matching(self, context_cls: type[Context]): """ Matching regular expressions """ - assert Context(distro="fedora-42").matches("distro ~ ^fedora-42$") - assert Context(distro="fedora-42").matches("distro ~ fedora") - assert Context(distro="fedora-42").matches("distro ~ fedora|rhel") - assert Context(distro="fedora-42").matches("distro ~ fedora-4.*") - assert not Context(distro="fedora-42").matches("distro ~ fedora-3.*") - assert not Context(distro="fedora-42").matches("distro ~ ubuntu") + assert context_cls(distro="fedora-42").matches("distro ~ ^fedora-42$") + assert context_cls(distro="fedora-42").matches("distro ~ fedora") + assert context_cls(distro="fedora-42").matches("distro ~ fedora|rhel") + assert context_cls(distro="fedora-42").matches("distro ~ fedora-4.*") + assert not context_cls(distro="fedora-42").matches("distro ~ fedora-3.*") + assert not context_cls(distro="fedora-42").matches("distro ~ ubuntu") - assert Context(arch="ppc64").matches("arch ~ ppc64.*") - assert Context(arch="ppc64le").matches("arch ~ ppc64.*") - assert not Context(arch="ppc64le").matches("arch ~ ppc64$") + assert context_cls(arch="ppc64").matches("arch ~ ppc64.*") + assert context_cls(arch="ppc64le").matches("arch ~ ppc64.*") + assert not context_cls(arch="ppc64le").matches("arch ~ ppc64$") - assert not Context(distro="fedora-42").matches("distro !~ ^fedora-42$") - assert not Context(distro="fedora-42").matches("distro !~ fedora") - assert not Context(distro="fedora-42").matches("distro !~ fedora|rhel") - assert not Context(distro="fedora-42").matches("distro !~ fedora-4.*") - assert Context(distro="fedora-42").matches("distro !~ fedora-3.*") - assert Context(distro="fedora-42").matches("distro !~ ubuntu") + assert not context_cls(distro="fedora-42").matches("distro !~ ^fedora-42$") + assert not context_cls(distro="fedora-42").matches("distro !~ fedora") + assert not context_cls(distro="fedora-42").matches("distro !~ fedora|rhel") + assert not context_cls(distro="fedora-42").matches("distro !~ fedora-4.*") + assert context_cls(distro="fedora-42").matches("distro !~ fedora-3.*") + assert context_cls(distro="fedora-42").matches("distro !~ ubuntu") - assert not Context(arch="ppc64").matches("arch !~ ppc64.*") - assert not Context(arch="ppc64le").matches("arch !~ ppc64.*") - assert Context(arch="ppc64le").matches("arch !~ ppc64$") + assert not context_cls(arch="ppc64").matches("arch !~ ppc64.*") + assert not context_cls(arch="ppc64le").matches("arch !~ ppc64.*") + assert context_cls(arch="ppc64le").matches("arch !~ ppc64$") class TestContextValue: @@ -372,7 +383,7 @@ def test_eq(self): assert ContextValue("foo") != ContextValue("bar") assert ContextValue("value-123") == ContextValue(["value", "123"]) - def test_version_cmp(self): + def test_version_cmp(self, context_cls: type[Context]): first = ContextValue("name") assert first.version_cmp(ContextValue("name")) == 0 assert first.version_cmp(ContextValue("name"), minor_mode=True) == 0 @@ -455,14 +466,14 @@ def test_version_cmp(self): # More error states with pytest.raises(CannotDecide): - first.version_cmp(Context()) # different object classes + first.version_cmp(context_cls()) # different object classes sixth = ContextValue([]) with pytest.raises(CannotDecide): sixth.version_cmp(first, minor_mode=True) with pytest.raises(CannotDecide): sixth.version_cmp(first) - assert sixth != Context() + assert sixth != context_cls() def test_version_cmp_fedora(self): """ @@ -496,9 +507,6 @@ def test_compare(self): assert ContextValue.compare("8", "19") == -1 - def test_string_conversion(self): - assert Context.parse_value(1) == ContextValue("1") - def test_compare_with_case(self): assert ContextValue._compare_with_case("1", "1", case_sensitive=True) assert ContextValue._compare_with_case("name_1", "name_1", case_sensitive=True) @@ -526,130 +534,123 @@ class TestParser: "defined dim", ] - def test_split_rule_to_groups(self): + def test_split_rule_to_groups(self, context_cls: type[Context]): """ Split to lists """ for invalid_rule in self.rule_groups_invalid: with pytest.raises(InvalidRule): - Context.split_rule_to_groups(invalid_rule) + context_cls.split_rule_to_groups(invalid_rule) # Valid wrt to group splitter - assert Context.split_rule_to_groups("bar") == [["bar"]] - assert Context.split_rule_to_groups(" bar ") == [["bar"]] - assert Context.split_rule_to_groups("foo = bar") == [["foo = bar"]] - assert Context.split_rule_to_groups( + assert context_cls.split_rule_to_groups("bar") == [["bar"]] + assert context_cls.split_rule_to_groups(" bar ") == [["bar"]] + assert context_cls.split_rule_to_groups("foo = bar") == [["foo = bar"]] + assert context_cls.split_rule_to_groups( "foo = bar and baz") == [["foo = bar", "baz"]] - assert Context.split_rule_to_groups( + assert context_cls.split_rule_to_groups( "foo = bar and is defined baz or is not defined foo") == [ ["foo = bar", "is defined baz"], ["is not defined foo"], ] - assert Context.split_rule_to_groups("a ~= b or c>d or is defined x") == [ + assert context_cls.split_rule_to_groups("a ~= b or c>d or is defined x") == [ ["a ~= b"], ["c>d"], ["is defined x"], ] - assert Context.split_rule_to_groups("a == b or true") == [ + assert context_cls.split_rule_to_groups("a == b or true") == [ ["a == b"], ["true"] ] - def test_split_expression(self): + def test_split_expression(self, context_cls: type[Context]): """ Split to dimension/operator/value tuple """ for invalid in self.invalid_expressions: with pytest.raises(InvalidRule): - Context.split_expression(invalid) - assert Context.split_expression("dim is defined") == ( + context_cls.split_expression(invalid) + assert context_cls.split_expression("dim is defined") == ( "dim", "is defined", None) - assert Context.split_expression("dim is not defined") == ( + assert context_cls.split_expression("dim is not defined") == ( "dim", "is not defined", None) - assert Context.split_expression("dim < value") == ( + assert context_cls.split_expression("dim < value") == ( "dim", "<", ["value"]) - assert Context.split_expression("dim < value-123") == ( + assert context_cls.split_expression("dim < value-123") == ( "dim", "<", ["value-123"]) - assert Context.split_expression("dim valueB or dim != valueC") == [ [ - ("dim", "<", [ContextValue("value")]), - ("dim", ">", [ContextValue("valueB")])], + ("dim", "<", ["value"]), + ("dim", ">", ["valueB"])], [ - ("dim", "!=", [ContextValue("valueC")])]] + ("dim", "!=", ["valueC"])]] class TestContext: - def test_creation(self): + def test_creation(self, context_cls: type[Context]): for created in [ - Context(dim_a="value", dim_b=["val"], dim_c=["foo", "bar"]), - Context("dim_a=value and dim_b=val and dim_c == foo,bar")]: - assert created._dimensions["dim_a"] == set([ContextValue("value")]) - assert created._dimensions["dim_b"] == set([ContextValue("val")]) - assert created._dimensions["dim_c"] == set( - [ContextValue("foo"), ContextValue("bar")]) - # Invalid ways to create Context + context_cls(dim_a="value", dim_b=["val"], dim_c=["foo", "bar"]), + context_cls("dim_a=value and dim_b=val and dim_c == foo,bar")]: + assert created._dimensions["dim_a"] == { + context_cls._context_dimensions.create("dim_a", "value")} + assert created._dimensions["dim_b"] == { + context_cls._context_dimensions.create("dim_b", "val")} + assert created._dimensions["dim_c"] == { + context_cls._context_dimensions.create( + "dim_c", "foo"), context_cls._context_dimensions.create( + "dim_c", "bar")} + # Invalid ways to create context_cls with pytest.raises(InvalidContext): - Context("a=b", "c=d") # Just argument + context_cls("a=b", "c=d") # Just argument with pytest.raises(InvalidContext): - Context("a=b or c=d") # Can't use OR + context_cls("a=b or c=d") # Can't use OR with pytest.raises(InvalidContext): - Context("a < d") # Operator other than =/== + context_cls("a < d") # Operator other than =/== - def test_prints(self): - c = Context() + def test_prints(self, context_cls: type[Context]): + c = context_cls() str(c) repr(c) - context = Context( - # nvr like single - distro="fedora-32", - # raw name single - pipeline="ci", - # raw name list - arch=["x86_64", "ppc64le"], - # nvr like list - components=["bash-5.0.17-1.fc32", "curl-7.69.1-6.fc32"], - ) - - def test_matches_groups(self): + def test_matches_groups(self, context_cls: type[Context]): """ and/or in rules with yes/no/cannotdecide outcome """ - context = Context(distro="centos-8.2.0") + context = context_cls(distro="centos-8.2.0") # Clear outcome assert context.matches("distro = centos-8.2.0 or distro = fedora") @@ -673,12 +674,12 @@ def test_matches_groups(self): with pytest.raises(CannotDecide): context.matches(undecidable) - def test_matches(self): + def test_matches(self, context_cls: type[Context]): """ yes/no/skip test per operator for matches """ - context = Context( + context = context_cls( distro="fedora-32", arch=["x86_64", "ppc64le"], component="bash-5.0.17-1.fc32", @@ -725,11 +726,11 @@ def test_matches(self): # missing version parts are allowed but at least one needs to be # defined with pytest.raises(CannotDecide): - Context(distro='fedora').matches("distro < fedora-33") - assert Context(distro='foo-1').matches("distro < foo-1.1") + context_cls(distro='fedora').matches("distro < fedora-33") + assert context_cls(distro='foo-1').matches("distro < foo-1.1") # '~<': - assert Context(distro='centos-8.3').matches("distro ~< centos-8.4") + assert context_cls(distro='centos-8.3').matches("distro ~< centos-8.4") assert context.matches("distro ~< fedora-33") with pytest.raises(CannotDecide): context.matches("distro ~< centos-8") @@ -794,35 +795,35 @@ def test_matches(self): context.matches("product ~> centos-8") assert not context.matches("distro ~> fedora") - def test_known_troublemakers(self): + def test_known_troublemakers(self, context_cls: type[Context]): """ Do not regress on these expressions """ # From fmf/issues/89: # following is true (missing left values are treated as lower) - assert Context(distro='foo-1').matches('distro < foo-1.1') + assert context_cls(distro='foo-1').matches('distro < foo-1.1') # but only if at least one version part is defined with pytest.raises(CannotDecide): - Context(distro='fedora').matches('distro < fedora-33') + context_cls(distro='fedora').matches('distro < fedora-33') # so use ~ if you need an explict Major check with pytest.raises(CannotDecide): - Context(distro='fedora').matches('distro ~< fedora-33') + context_cls(distro='fedora').matches('distro ~< fedora-33') - assert Context(distro='fedora-33').matches('distro == fedora') + assert context_cls(distro='fedora-33').matches('distro == fedora') with pytest.raises(CannotDecide): - Context("module = py:5.28").matches("module > perl:5.28") + context_cls("module = py:5.28").matches("module > perl:5.28") with pytest.raises(CannotDecide): - Context("module = py:5").matches("module > perl:5.28") + context_cls("module = py:5").matches("module > perl:5.28") with pytest.raises(CannotDecide): - Context("module = py:5").matches("module >= perl:5.28") + context_cls("module = py:5").matches("module >= perl:5.28") with pytest.raises(CannotDecide): - Context("distro = centos").matches("distro >= fedora") + context_cls("distro = centos").matches("distro >= fedora") - assert Context("distro = centos").matches("distro != fedora") - assert not Context("distro = centos").matches("distro == fedora") + assert context_cls("distro = centos").matches("distro != fedora") + assert not context_cls("distro = centos").matches("distro == fedora") - rhel7 = Context("distro = rhel-7") + rhel7 = context_cls("distro = rhel-7") assert rhel7.matches("distro == rhel") assert rhel7.matches("distro == rhel-7") assert not rhel7.matches("distro == rhel-7.3") @@ -839,12 +840,12 @@ def test_known_troublemakers(self): # Checking `CannotDecide or False` for distro in "fedora-33 fedora-34 centos-7.7".split(): with pytest.raises(CannotDecide): - Context(distro=distro).matches(expr) + context_cls(distro=distro).matches(expr) # Checking `CannotDecide or True` - assert Context(distro="centos-6.5").matches(expr) - assert Context(distro="fedora-32").matches(expr) + assert context_cls(distro="centos-6.5").matches(expr) + assert context_cls(distro="fedora-32").matches(expr) - def test_cannotdecides(self): + def test_cannotdecides(self, context_cls: type[Context]): # https://github.com/psss/fmf/issues/117 # CannotDecide and True = True and CannotDecide = CannotDecide # CannotDecide and False = False and CannotDecide = False @@ -853,7 +854,7 @@ def test_cannotdecides(self): _true = "foo == bar" _false = "foo != bar" _cannot = "baz == bar" - env = Context(foo="bar") + env = context_cls(foo="bar") for a, op, b in [ (_cannot, 'and', _true), (_true, 'and', _cannot), @@ -882,60 +883,62 @@ class TestOperators: more thorough testing for operations """ - context = Context( - # nvr like single - distro="fedora-32", - # raw name single - pipeline="ci", - # raw name list - arch=["x86_64", "ppc64le"], - # nvr like list - components=["bash-5.0.17-1.fc32", "curl-7.69.1-6.fc32"], - ) + @pytest.fixture() + def context(self, context_cls: type[Context]) -> Context: + return context_cls( + # nvr like single + distro="fedora-32", + # raw name single + pipeline="ci", + # raw name list + arch=["x86_64", "ppc64le"], + # nvr like list + components=["bash-5.0.17-1.fc32", "curl-7.69.1-6.fc32"], + ) # is (not) defined is too simple and covered by test_matches - def test_equal(self): - assert self.context.matches("distro=fedora-32") + def test_equal(self, context: Context): + assert context.matches("distro=fedora-32") # One of them matches - assert self.context.matches("distro=fedora-32,centos-8") - assert not self.context.matches("distro=fedora-3") + assert context.matches("distro=fedora-32,centos-8") + assert not context.matches("distro=fedora-3") # Version-like comparison - assert self.context.matches("distro=fedora") + assert context.matches("distro=fedora") - assert self.context.matches("pipeline=ci") + assert context.matches("pipeline=ci") # One of them matches - assert self.context.matches("pipeline=ci,devnull") - assert not self.context.matches("pipeline=devnull") + assert context.matches("pipeline=ci,devnull") + assert not context.matches("pipeline=devnull") - assert self.context.matches("arch=x86_64") + assert context.matches("arch=x86_64") # One of them matches - assert self.context.matches("arch=x86_64,aarch64") - assert not self.context.matches("arch=aarch64") - assert not self.context.matches("arch=aarch64,s390x") + assert context.matches("arch=x86_64,aarch64") + assert not context.matches("arch=aarch64") + assert not context.matches("arch=aarch64,s390x") - def test_not_equal(self): - assert not self.context.matches("distro!=fedora-32") + def test_not_equal(self, context: Context): + assert not context.matches("distro!=fedora-32") # One of them not matches - assert self.context.matches("distro!=fedora-32,centos-8") - assert self.context.matches("distro!=fedora-3") + assert context.matches("distro!=fedora-32,centos-8") + assert context.matches("distro!=fedora-3") # Version-like comparison - assert not self.context.matches("distro!=fedora") + assert not context.matches("distro!=fedora") - assert not self.context.matches("pipeline!=ci") + assert not context.matches("pipeline!=ci") # One of them matches - assert self.context.matches("pipeline!=ci,devnull") - assert self.context.matches("pipeline!=devnull") + assert context.matches("pipeline!=ci,devnull") + assert context.matches("pipeline!=devnull") # One of them not matches - assert self.context.matches("arch!=x86_64") + assert context.matches("arch!=x86_64") # One of them not matches - assert self.context.matches("arch!=x86_64,aarch64") - assert self.context.matches("arch!=aarch64") - assert self.context.matches("arch!=aarch64,s390x") + assert context.matches("arch!=x86_64,aarch64") + assert context.matches("arch!=aarch64") + assert context.matches("arch!=aarch64,s390x") - def test_minor_eq(self): - centos = Context(distro="centos-8.2.0") + def test_minor_eq(self, context_cls: type[Context]): + centos = context_cls(distro="centos-8.2.0") for not_equal in ["fedora", "fedora-3", "centos-7"]: assert not centos.matches("distro ~= {}".format(not_equal)) assert centos.matches("distro ~= centos") @@ -947,7 +950,7 @@ def test_minor_eq(self): with pytest.raises(CannotDecide): centos.matches("distro ~= centos-8.2.0.0") - multi = Context(distro=["centos-8.2.0", "centos-7.6.0"]) + multi = context_cls(distro=["centos-8.2.0", "centos-7.6.0"]) for not_equal in [ "fedora", "fedora-3", @@ -963,7 +966,7 @@ def test_minor_eq(self): assert multi.matches("distro ~= centos-7.6") assert not multi.matches("distro ~= centos-7.5") - multi_rh = Context(distro=["centos-8.2.0", "rhel-8.2.0", "fedora-40"]) + multi_rh = context_cls(distro=["centos-8.2.0", "rhel-8.2.0", "fedora-40"]) assert multi_rh.matches("distro ~= centos") assert multi_rh.matches("distro ~= rhel") assert multi_rh.matches("distro ~= fedora") @@ -975,3 +978,66 @@ def test_minor_eq(self): assert not multi_rh.matches("distro ~= centos-9") assert not multi_rh.matches("distro ~= rhel-9") assert not multi_rh.matches("distro ~= fedora-41") + + +class TestContextDimension: + @pytest.fixture(scope="function") + def context(self, custom_context_cls: type[Context]) -> Context: + class FooContext(custom_context_cls._context_dimensions[str]): + _dimension_name = "foo" + + # For simplicity, make this just compare string + + @classmethod + def _make_value(cls, raw_value: str) -> str: + return raw_value + + def _op_eq(self, other: str) -> bool: + return self.value == other + + def _op_less(self, other: str) -> bool: + return self.value < other + + def _op_less_or_equal(self, other: str) -> bool: + return self.value <= other + + def _op_greater(self, other: str) -> bool: + return self.value > other + + def _op_greater_or_equal(self, other: str) -> bool: + return self.value >= other + + def _op_minor_eq(self, other: str) -> bool: + return self._op_eq(other) + + def _op_minor_less(self, other: str) -> bool: + return self._op_less(other) + + def _op_minor_less_or_equal(self, other: str) -> bool: + return self._op_minor_less_or_equal(other) + + def _op_minor_greater(self, other: str) -> bool: + return self._op_greater(other) + + def _op_minor_greater_or_equal(self, other: str) -> bool: + return self._op_greater_or_equal(other) + + return custom_context_cls( + foo="foo-10", + bar="bar-10", + ) + + def test_custom_dimension(self, context: Context): + # foo is a custom dimension following text comparison ("10" < "2") + foo_dimensions = list(context._dimensions["foo"]) + foo_class = foo_dimensions[0].__class__ + assert foo_class.__name__ == "FooContext" + assert context.matches("foo < foo-2") + assert context.matches("foo > bar-2") + + # bar is a ContextValue, so it should follow test_matches (int(10) > int(2)) + bar_dimensions = list(context._dimensions["bar"]) + assert isinstance(bar_dimensions[0], context._context_dimensions._default_dimension_cls) + assert context.matches("bar > bar-2") + with pytest.raises(CannotDecide): + context.matches("bar < foo-2")