Repository: teemtee/fmf

Issue:
Add operator for REGEX matching in context
Add two operators:
`~` for match
`!~` for not match

Note we have `~=` and similar for "major version" comparison.
However since `~` is used in tmt hardware it make sense to use such symbol here as well

Hints from issue comments:
@LecrisUT: Let me add to this, the regex matches should be exposed as context objects:
- by default all regex matches should be stored under a `regex_match` context
- for clarity, the regex should only be allowed to match once so `regex_match` is singular valued
- regex match groups are stored as the dictionary of `regex_match`
  - always save the matches by their group `int` keys
  - if the group is named, include the `str` key-values
  - there is always the match group `0` that is the full regex match

This could be particularly useful in combination with #227 @psss: The `regex_match` sounds like a useful feature. But it will need some discussion as for now the dimension values can only be list of strings, not dictionaries. Let's start small. I've implemented the basic support for the regular expression matching in #272. We can extend the functionality later.

Solution patch:
diff --git a/docs/context.rst b/docs/context.rst
index 6d4ea7f..5250653 100644
--- a/docs/context.rst
+++ b/docs/context.rst
@@ -47,7 +47,7 @@ supported operators consult the following grammar outline::
     expression ::= 'true' | 'false'
     dimension ::= [[:alnum:]]+
     binary_operator ::= '==' | '!=' | '<' | '<=' | '>' | '>=' |
-        '~=' | '~!=' | '~<' | '~<=' | '~>' | '~>='
+        '~=' | '~!=' | '~<' | '~<=' | '~>' | '~>=' | '~' | '!~'
     unary_operator ::= 'is defined' | 'is not defined'
     values ::= value (',' value)*
     value ::= [[:alnum:]]+
@@ -65,6 +65,12 @@ Let's demonstrate the syntax on a couple of real-life examples::
     # check whether a dimension is defined
     collection is not defined
 
+    # search dimension value for a regular expression
+    initiator ~ .*-ci
+
+    # make sure that the value does not match given regular expression
+    arch !~ ppc64.*
+
     # disable adjust rule (e.g. during debugging / experimenting)
     false and <original rule>
 
diff --git a/docs/releases.rst b/docs/releases.rst
index 944383d..154cabf 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -5,6 +5,14 @@
 ======================
 
 
+fmf-1.6.0
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In order to search :ref:`context` dimension values using regular
+expressions, it is now possible to use operator ``~`` for matching
+patterns and operator ``!~`` for non matching patterns.
+
+
 fmf-1.5.0
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/fmf/context.py b/fmf/context.py
index 61404f0..7ba7170 100644
--- a/fmf/context.py
+++ b/fmf/context.py
@@ -34,15 +34,18 @@ class InvalidContext(Exception):
 class ContextValue:
     """ Value for dimension """
 
-    def __init__(self, origin):
+    def __init__(self, raw):
         """
         ContextValue("foo-1.2.3")
         ContextValue(["foo", "1", "2", "3"])
         """
-        if isinstance(origin, (tuple, list)):
-            self._to_compare = tuple(origin)
+        if isinstance(raw, (tuple, list)):
+            self._to_compare = tuple(raw)
         else:
-            self._to_compare = self._split_to_version(origin)
+            self._to_compare = self._split_to_version(raw)
+
+        # Store the original string for regexp processing
+        self.raw = raw
 
     def __eq__(self, other):
         if isinstance(other, self.__class__):
@@ -238,6 +241,22 @@ class Context:
 
         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 """
 
@@ -371,6 +390,8 @@ class Context:
         "~>=": _op_minor_greater_or_equal,
         ">": _op_greater,
         "~>": _op_minor_greater,
+        "~": _op_match,
+        "!~": _op_not_match,
         }
 
     # Triple expression: dimension operator values
@@ -379,7 +400,7 @@ class Context:
         r"(\w+)"
         + r"\s*("
         + r"|".join(
-            set(operator_map.keys()) - {"is defined", "is not defined"})
+            [key for key in operator_map if key not in ["is defined", "is not defined"]])
         + r")\s*"
         + r"([^=].*)")
     # Double expression: dimension operator


Test patch:
diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py
index 5d17891..915d57c 100644
--- a/tests/unit/test_context.py
+++ b/tests/unit/test_context.py
@@ -282,6 +282,31 @@ class TestExample:
         assert python.matches("component > python3-3.7")
         assert python.matches("component < PYTHON3-3.9")
 
+    def test_regular_expression_matching(self):
+        """ 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(arch="ppc64").matches("arch ~ ppc64.*")
+        assert Context(arch="ppc64le").matches("arch ~ ppc64.*")
+        assert not Context(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(arch="ppc64").matches("arch !~ ppc64.*")
+        assert not Context(arch="ppc64le").matches("arch !~ ppc64.*")
+        assert Context(arch="ppc64le").matches("arch !~ ppc64$")
+
 
 class TestContextValue:
     impossible_split = ["x86_64", "ppc64", "fips", "errata"]