Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow case-insensitive matching of context dimension values #195

Merged
merged 1 commit into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/context.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ Values are always converted to a string representation. Each
value is treated as if it was a component with version. Name of
the dimension doesn't matter, all are treated equally.

Values are case-sensitive by default, which means that values like
``centos`` and ``CentOS`` are considered different. When calling
the ``adjust()`` method on the tree, ``case_sensitive=False`` can
be used to make the value comparison case insensitive.

The characters ``:`` or ``.`` or ``-`` are used as version
separators and are handled in the same way. The following examples
demonstrate how the ``name`` and ``version`` parts are parsed::
Expand Down
12 changes: 10 additions & 2 deletions fmf/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ def update(self, data):
log.debug("Data for '{0}' updated.".format(self))
log.data(pretty(self.data))

def adjust(self, context, key='adjust', undecided='skip', decision_callback=None):
def adjust(self, context, key='adjust', undecided='skip',
case_sensitive=True, decision_callback=None):
"""
Adjust tree data based on provided context and rules

Expand All @@ -355,6 +356,10 @@ 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.
Expand All @@ -380,6 +385,8 @@ class describing the environment context. By default, the key
except KeyError:
rules = []

context.case_sensitive = case_sensitive

# Check and apply each rule
for rule in rules:

Expand Down Expand Up @@ -435,7 +442,8 @@ class describing the environment context. By default, the key

# Adjust all child nodes as well
for child in self.children.values():
child.adjust(context, key, undecided, decision_callback=decision_callback)
child.adjust(context, key, undecided,
case_sensitive=case_sensitive, decision_callback=decision_callback)

def get(self, name=None, default=None):
"""
Expand Down
80 changes: 61 additions & 19 deletions fmf/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def __str__(self):
def __repr__(self):
return "{}({})".format(self.__class__.__name__, repr(self._to_compare))

def version_cmp(self, other, minor_mode=False, ordered=True):
def version_cmp(self, other, minor_mode=False, ordered=True, case_sensitive=True):
"""
Comparing two ContextValue objects

Expand Down Expand Up @@ -87,14 +87,19 @@ def version_cmp(self, other, minor_mode=False, ordered=True):
-1 when self < other
0 when self == other
1 when self > other

case_sensitive:
False ... ignore case when comparing
True ... case matters when comparing
"""
if not isinstance(other, self.__class__):
raise CannotDecide("Invalid types.")

if len(self._to_compare) == 0 or len(other._to_compare) == 0:
raise CannotDecide("Empty name part.")

if self._to_compare[0] != other._to_compare[0]:
if not self._compare_with_case(
self._to_compare[0], other._to_compare[0], case_sensitive=case_sensitive):
if ordered:
raise CannotDecide(
"Name parts differ, cannot compare for order.")
Expand All @@ -103,7 +108,8 @@ def version_cmp(self, other, minor_mode=False, ordered=True):
if minor_mode and len(other._to_compare) > 1:
# right side cares about 'major'
try:
if self._to_compare[1] != other._to_compare[1]:
if not self._compare_with_case(
self._to_compare[1], other._to_compare[1], case_sensitive=case_sensitive):
if ordered:
if len(other._to_compare) > 2:
# future Y comparison not allowed
Expand All @@ -119,7 +125,7 @@ def version_cmp(self, other, minor_mode=False, ordered=True):
# Now we can compare version parts as long as other needs to
compared = 0
for first, second in zip(self._to_compare[1:], other._to_compare[1:]):
compared = self.compare(first, second)
compared = self.compare(first, second, case_sensitive=case_sensitive)
if compared != 0: # not equal - return immediately
return compared
leftover_version_parts = len(other._to_compare) - len(self._to_compare)
Expand All @@ -138,7 +144,7 @@ def version_cmp(self, other, minor_mode=False, ordered=True):
return -1 # other is larger (more pars)

@staticmethod
def compare(first, second):
def compare(first, second, case_sensitive=True):
""" compare two version parts """
# Ideally use `from packaging import version` but we need older
# python support too so very rough
Expand All @@ -148,12 +154,33 @@ def compare(first, second):
second_version = int(second)
except ValueError:
# fallback to compare as strings
first_version = first
second_version = second
if case_sensitive:
first_version = first
second_version = second
else:
first_version = first.casefold()
second_version = second.casefold()
return (
(first_version > second_version) -
(first_version < second_version))

@staticmethod
def _compare_with_case(first, second, case_sensitive=True):
"""
Compare two values based on the case sensitivity setting.

:param first: first value
:param second: second value
:param case_sensitive: If True (default), the comparison is case-sensitive.
If False, the comparison is case-insensitive.

:return: True if the values match, False otherwise.
:rtype: bool
"""
if case_sensitive:
return first == second
return first.casefold() == second.casefold()

@staticmethod
def _split_to_version(text):
"""
Expand Down Expand Up @@ -197,15 +224,17 @@ def _op_eq(self, dimension_name, values):
""" '=' operator """

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(it_val, ordered=False) == 0
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) != 0
return dimension_value.version_cmp(
it_val, ordered=False, case_sensitive=self.case_sensitive) != 0

return self._op_core(dimension_name, values, comparator)

Expand All @@ -214,7 +243,7 @@ def _op_minor_eq(self, dimension_name, values):

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(
it_val, minor_mode=True, ordered=False) == 0
it_val, minor_mode=True, ordered=False, case_sensitive=self.case_sensitive) == 0

return self._op_core(dimension_name, values, comparator)

Expand All @@ -223,7 +252,7 @@ def _op_minor_not_eq(self, dimension_name, values):

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(
it_val, minor_mode=True, ordered=False) != 0
it_val, minor_mode=True, ordered=False, case_sensitive=self.case_sensitive) != 0

return self._op_core(dimension_name, values, comparator)

Expand All @@ -232,7 +261,7 @@ def _op_minor_less_or_eq(self, dimension_name, values):

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(
it_val, minor_mode=True, ordered=True) <= 0
it_val, minor_mode=True, ordered=True, case_sensitive=self.case_sensitive) <= 0

return self._op_core(dimension_name, values, comparator)

Expand All @@ -241,31 +270,34 @@ def _op_minor_less(self, dimension_name, values):

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(
it_val, minor_mode=True, ordered=True) < 0
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) < 0
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) <= 0
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) >= 0
return dimension_value.version_cmp(
it_val, ordered=True, case_sensitive=self.case_sensitive) >= 0

return self._op_core(dimension_name, values, comparator)

Expand All @@ -274,15 +306,16 @@ def _op_minor_greater_or_equal(self, dimension_name, values):

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(
it_val, minor_mode=True, ordered=True) >= 0
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) > 0
return dimension_value.version_cmp(
it_val, ordered=True, case_sensitive=self.case_sensitive) > 0

return self._op_core(dimension_name, values, comparator)

Expand All @@ -291,7 +324,7 @@ def _op_minor_greater(self, dimension_name, values):

def comparator(dimension_value, it_val):
return dimension_value.version_cmp(
it_val, minor_mode=True, ordered=True) > 0
it_val, minor_mode=True, ordered=True, case_sensitive=self.case_sensitive) > 0

return self._op_core(dimension_name, values, comparator)

Expand Down Expand Up @@ -372,6 +405,7 @@ def __init__(self, *args, **kwargs):
:raises InvalidContext
"""
self._dimensions = {}
self.case_sensitive = True

# Initialized with rule
if args:
Expand All @@ -393,6 +427,14 @@ def __init__(self, *args, **kwargs):
[self.parse_value(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):
"""
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_adjust.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,15 @@ def test_adjust_callback(self, mini, fedora, centos):
mock_callback = MagicMock(name='<mock>callback')
mini.adjust(centos, decision_callback=mock_callback)
mock_callback.assert_called_once_with(mini, rule, True)

def test_case_sensitive(self, mini, centos):
""" Make sure the adjust rules are case-sensitive by default """
mini.data['adjust'] = dict(when='distro = CentOS', enabled=False)
mini.adjust(centos)
assert mini.get('enabled') is True

def test_case_insensitive(self, mini, centos):
""" Make sure the adjust rules are case-insensitive when requested """
mini.data['adjust'] = dict(when='distro = CentOS', enabled=False)
mini.adjust(centos, case_sensitive=False)
assert mini.get('enabled') is False
34 changes: 34 additions & 0 deletions tests/unit/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,19 @@ def test_comma(self):
assert distro.matches("distro < centos-stream-9, fedora-34")
assert not distro.matches("distro < fedora-34, centos-stream-8")

def test_case_insensitive(self):
""" Test for case-insensitive matching """
python = Context(component="python3-3.8.5-5.fc32")
python.case_sensitive = False

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")


class TestContextValue:
impossible_split = ["x86_64", "ppc64", "fips", "errata"]
Expand Down Expand Up @@ -304,6 +317,9 @@ def test_version_cmp(self):
assert first.version_cmp(ContextValue("name"), minor_mode=True) == 0
assert first.version_cmp(ContextValue("name"), ordered=True) == 0
assert first.version_cmp(ContextValue("name"), ordered=False) == 0
assert first.version_cmp(ContextValue("NAME"), ordered=True, case_sensitive=False) == 0
assert first.version_cmp(ContextValue("NAME"), ordered=False, case_sensitive=False) == 0
assert first.version_cmp(ContextValue("NAME"), ordered=False, case_sensitive=True) == 1

assert first.version_cmp(ContextValue("name-1"), ordered=False) == 1
with pytest.raises(CannotDecide):
Expand All @@ -329,6 +345,11 @@ def test_version_cmp(self):
ContextValue("name-1-2"),
minor_mode=True,
ordered=False)
with pytest.raises(CannotDecide):
first.version_cmp(
ContextValue("NAME"),
ordered=True,
case_sensitive=True)

second = ContextValue("name-1-2-3")
assert second.version_cmp(ContextValue("name"), ordered=False) == 0
Expand Down Expand Up @@ -403,6 +424,8 @@ def test_prints(self):
def test_compare(self):
assert ContextValue.compare("1", "1") == 0
assert ContextValue.compare("a", "a") == 0
assert ContextValue.compare("1", "1", case_sensitive=False) == 0
assert ContextValue.compare("A", "a", case_sensitive=False) == 0

assert ContextValue.compare("rawhide", "aaa") == 1
assert ContextValue.compare("rawhide", "9999") == 1
Expand All @@ -412,6 +435,17 @@ def test_compare(self):
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)
assert not ContextValue._compare_with_case("NAME", "name", case_sensitive=True)
assert not ContextValue._compare_with_case("name_1", "NAME_1", case_sensitive=True)

assert ContextValue._compare_with_case("1", "1", case_sensitive=False)
assert ContextValue._compare_with_case("name_1", "name_1", case_sensitive=False)
assert ContextValue._compare_with_case("NAME", "name", case_sensitive=False)
assert ContextValue._compare_with_case("name_1", "NAME_1", case_sensitive=False)


class TestParser:
# Missing expression
Expand Down
Loading