From 162fbf30f0123f4dd498d0c25d69e8b4cf702a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 1 Aug 2025 14:18:59 +0200 Subject: [PATCH 01/25] utils/fuzzy.py: Add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/fuzzy.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/robot/utils/fuzzy.py diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py new file mode 100644 index 00000000000..0313abc9362 --- /dev/null +++ b/src/robot/utils/fuzzy.py @@ -0,0 +1,32 @@ + +import fuzzysearch + +def fuzzy_find(buffer, expected, percent_match=None, max_errors=None, ignore_case=False): + found = fuzzy_find_all(buffer, expected, percent_match, max_errors, ignore_case) + + if len(found) > 0: + return found[0] + return None + +def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=None, ignore_case:bool=False): + + if max_errors is not None: + max_errors = int(max_errors) + max_l_dist = max_errors + elif percent_match is not None: + percent_match = float(percent_match) + percent_match = min(100, max(0, percent_match)) # limit to 0-100 + percent_errors = 100 - percent_match + max_l_dist = round(len(expected) * percent_errors / 100.0) + else: + max_l_dist = None + + + if ignore_case: + matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist) + # change matched to contain original, possibly uppercase, input + for match in matches: + match.matched = buffer[match.start:match.end] + else: + matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist) + return matches From 5ade15339a6cddb237c82c06a961864fc574180b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 1 Aug 2025 14:19:27 +0200 Subject: [PATCH 02/25] libraries/Telnet.py: Add fuzzy string comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 5c6aa9224a1..4b9bd0dccfe 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -21,6 +21,7 @@ import time from contextlib import contextmanager +import robot.utils.fuzzy as fuzzy try: import pyte except ImportError: @@ -983,7 +984,36 @@ def read_until(self, expected, loglevel=None): if not success: raise NoMatchError(expected, self._timeout, output) return output + + def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, loglevel=None): + """Reads output until ``expected`` text is matched using fuzzy comparison. + Text up to and including the match is returned and logged. If no match + is found, this keyword fails. How much to wait for the output depends + on the [#Configuration|configured timeout]. + + Set `percent_match` to match strings where `percent_match` of characters are correct + Set `max_errors` to match strings, which differ at a maximum of `max_errors` characters + `max_errors` overrides `percent_match` when both are set. + + See `Logging` section for more information about log levels. Use + `Read Until Regexp` if more complex matching is needed. + """ + success, output = self._read_until_fuzzy(expected, percent_match, max_errors) + self._log(output, loglevel) + if not success: + raise NoMatchError(expected, self._timeout, output) + return output + + def _read_until_fuzzy(self, expected, percent_match=None, max_errors=None): + self._verify_connection() + if self._terminal_emulator: + return self._terminal_read_until_fuzzy(expected) + expected = self._encode(expected) + output = telnetlib.Telnet.read_until_fuzzy(self, expected, self._timeout, percent_match, max_errors) + found = fuzzy.fuzzy_find(output, expected, percent_match, max_errors) is not None + return found, self._decode(output) + def _read_until(self, expected): self._verify_connection() if self._terminal_emulator: @@ -1010,6 +1040,20 @@ def _terminal_read_until(self, expected): if output: return True, output return False, self._terminal_emulator.read() + + def _terminal_read_until_fuzzy(self, expected, percent_match=None, max_errors=None): + max_time = time.time() + self._timeout + output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors) + if output: + return True, output + while time.time() < max_time: + output = telnetlib.Telnet.read_until_fuzzy(self, self._encode(expected), + self._terminal_frequency, percent_match, max_errors) + self._terminal_emulator.feed(self._decode(output)) + output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors) + if output: + return True, output + return False, self._terminal_emulator.read() def _read_until_regexp(self, *expected): self._verify_connection() @@ -1312,6 +1356,21 @@ def read_until(self, expected): return current_out[: exp_index + len(expected)] return None + def read_until_fuzzy(self, expected, percent_match=None, max_errors=None): + current_out = self.current_output + + match = fuzzy.fuzzy_find(current_out, expected, percent_match, max_errors) + if match is None: + return None + + exp_index = match.start + match_len = len(match.matched) + current_out.find(expected) + if exp_index != -1: + self._update_buffer(current_out[exp_index+match_len:]) + return current_out[:exp_index+match_len] + return None + def read_until_regexp(self, regexp_list): current_out = self.current_output for rgx in regexp_list: @@ -1346,3 +1405,5 @@ def _get_message(self): if self.output is not None: msg += " Output:\n" + self.output return msg + + From 4565ec3244679bd1bded5de45adf5c58d046f2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 1 Aug 2025 14:19:40 +0200 Subject: [PATCH 03/25] libraries/String.py: Add fuzzy string comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/String.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index e6483f906da..e846cc00ddc 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -15,6 +15,7 @@ import os import re +import robot.utils.fuzzy as fuzzy from fnmatch import fnmatchcase from random import randint from string import ascii_lowercase, ascii_uppercase, digits @@ -287,6 +288,24 @@ def get_line_number_containing_string(self, string, pattern, case_insensitive=Fa else: ret = 0 return ret + + def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match=None, max_errors=None, case_insensitive=False): + """Returns line number of the given ``string`` that contain the ``pattern``. + The ``pattern`` is always considered to be a normal string, not a glob + or regexp pattern. A line matches if the ``pattern`` is found anywhere + on it. + + Examples: + | ${lines} = | Get Line Number Containing String | ${result} | An example | + | ${ret} = | Get Line Number Containing String | ${ret} | FAIL | case-insensitive | + + If multiple line match only line number of first occurrence is returned. + """ + for n,l in enumerate(string.splitlines()): + matches = fuzzy.fuzzy_find(l, pattern, percent_match, max_errors, ignore_case=case_insensitive) + if len(matches) > 0: + return n + return 0 def get_lines_containing_string( self, @@ -369,6 +388,10 @@ def get_lines_matching_pattern( matches = lambda line: fnmatchcase(line, pattern) return self._get_matching_lines(string, matches) + def get_lines_matching_fuzzy(self, string, pattern, percent_match=None, max_errors=None, case_insensitive=False): + matches = lambda line: len(fuzzy.fuzzy_find(line.lower(), pattern.lower(), percent_match, max_errors, ignore_case=case_insensitive)) > 0 + return self._get_matching_lines(string, matches) + def get_lines_matching_regexp( self, string, From bf1ac0643af7931531068f5772e454f38d730810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 1 Aug 2025 14:47:21 +0200 Subject: [PATCH 04/25] utils/asserts.py: Add fuzzy comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/asserts.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/robot/utils/asserts.py b/src/robot/utils/asserts.py index 939e5416626..95d8c68a37d 100644 --- a/src/robot/utils/asserts.py +++ b/src/robot/utils/asserts.py @@ -97,6 +97,7 @@ def test_new_style(self): from .robottypes import type_name from .unic import safe_str +import robot.utils.fuzzy as fuzzy def fail(msg=None): """Fail test immediately with the given message.""" @@ -179,6 +180,19 @@ def assert_equal(first, second, msg=None, values=True, formatter=safe_str): if not first == second: # noqa: SIM201 _report_inequality(first, second, "!=", msg, values, formatter) +def assert_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None): + """Fail if given objects are unequal as determined by fuzzy comparison. Default is 90% similarity.""" + match = fuzzy._fuzzy_find(first, second, percent_match, max_errors) + if not len(match) > 0: + _report_inequality(first, second, '!=', msg, values, formatter) + +def assert_not_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None): + """Fail if given objects are equal as determined by fuzzy comparison. Default is 90% similarity.""" + match = fuzzy._fuzzy_find(first, second, percent_match, max_errors) + if len(match) > 0: + _report_inequality(first, second, '!=', msg, values, formatter) + + def assert_not_equal(first, second, msg=None, values=True, formatter=safe_str): """Fail if given objects are equal as determined by the '==' operator.""" From c163bc5fde1fe53af8d51c1e509e0c4d7a5c4945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 1 Aug 2025 14:59:15 +0200 Subject: [PATCH 05/25] libraries/Builtin.py: Add fuzzy comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/BuiltIn.py | 360 ++++++++++++++++++++++++++++++++- 1 file changed, 359 insertions(+), 1 deletion(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index bdbf6918bac..07fdeb4467e 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -18,6 +18,7 @@ import time from collections import OrderedDict from collections.abc import Sequence +import robot.utils.fuzzy as fuzzy from robot.api import logger, SkipExecution from robot.api.deco import keyword @@ -33,7 +34,7 @@ parse_re_flags, parse_time, plural_or_not as s, prepr, safe_str, secs_to_timestr, seq2str, split_from_equals, timestr_to_secs ) -from robot.utils.asserts import assert_equal, assert_not_equal +from robot.utils.asserts import assert_equal, assert_not_equal, assert_equal_fuzzy, assert_not_equal_fuzzy from robot.variables import ( DictVariableResolver, evaluate_expression, is_dict_variable, is_list_variable, search_variable, VariableResolver @@ -104,6 +105,10 @@ def _matches(self, string, pattern, caseless=False): # Must use this instead of fnmatch when string may contain newlines. matcher = Matcher(pattern, caseless=caseless, spaceless=False) return matcher.match(string) + + def _matches_fuzzy(self, string, pattern, percent_match=None, max_errors=None, caseless=False): + matches = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors) + return matches is not None def _is_true(self, condition): if isinstance(condition, str): @@ -704,6 +709,31 @@ def _should_be_equal(self, first, second, msg, values, formatter="str"): if include_values and isinstance(first, str) and isinstance(second, str): self._raise_multi_diff(first, second, msg, formatter) assert_equal(first, second, msg, include_values, formatter) + + def should_be_equal_fuzzy(self, first, second, msg=None, values=True, + ignore_case=False, formatter='str', strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + self._log_types_at_info_if_different(first, second) + if isinstance(first, str) and isinstance(second, str): + if ignore_case: + first = first.lower() + second = second.lower() + if strip_spaces: + first = self._strip_spaces(first, strip_spaces) + second = self._strip_spaces(second, strip_spaces) + if collapse_spaces: + first = self._collapse_spaces(first) + second = self._collapse_spaces(second) + self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors) + + def _should_be_equal_fuzzy(self, first, second, msg, values, formatter='str', percent_match=None, max_errors=None): + include_values = self._include_values(values) + formatter = self._get_formatter(formatter) + if first == second: + return + if include_values and isinstance(first, str) and isinstance(second, safe_str): + self._raise_multi_diff(first, second, msg, formatter) + assert_equal_fuzzy(first, second, msg, include_values, formatter, percent_match, max_errors) def _log_types_at_info_if_different(self, first, second): level = "DEBUG" if type(first) is type(second) else "INFO" @@ -791,9 +821,28 @@ def should_not_be_equal( second = self._collapse_spaces(second) self._should_not_be_equal(first, second, msg, values) + def should_not_be_equal_fuzzy(self, first, second, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, match_percent=None, max_errors=None): + self._log_types_at_info_if_different(first, second) + if isinstance(first, str) and isinstance(second, str): + if ignore_case: + first = first.lower() + second = second.lower() + if strip_spaces: + first = self._strip_spaces(first, strip_spaces) + second = self._strip_spaces(second, strip_spaces) + if collapse_spaces: + first = self._collapse_spaces(first) + second = self._collapse_spaces(second) + self._should_not_be_equal_fuzzy(first, second, msg, values, match_percent=None, max_errors=None) + def _should_not_be_equal(self, first, second, msg, values): assert_not_equal(first, second, msg, self._include_values(values)) + def _should_not_be_equal_fuzzy(self, first, second, msg, values, match_percent, max_errors): + assert_not_equal_fuzzy(first, second, msg, self._include_values(values), match_percent, max_errors) + def should_not_be_equal_as_integers( self, first, @@ -964,6 +1013,23 @@ def should_not_be_equal_as_strings( second = self._collapse_spaces(second) self._should_not_be_equal(first, second, msg, values) + def should_not_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + self._log_types_at_info_if_different(first, second) + first = safe_str(first) + second = safe_str(second) + if ignore_case: + first = first.lower() + second = second.lower() + if strip_spaces: + first = self._strip_spaces(first, strip_spaces) + second = self._strip_spaces(second, strip_spaces) + if collapse_spaces: + first = self._collapse_spaces(first) + second = self._collapse_spaces(second) + self._should_not_be_equal_fuzzy(first, second, msg, values, percent_match, max_errors) + def should_be_equal_as_strings( self, first, @@ -1012,6 +1078,23 @@ def should_be_equal_as_strings( first = self._collapse_spaces(first) second = self._collapse_spaces(second) self._should_be_equal(first, second, msg, values, formatter) + + def should_be_equal_as_strings(self, first, second, msg=None, values=True, + ignore_case=False, strip_spaces=False, + formatter='str', collapse_spaces=False, percent_match=None, max_errors=None): + self._log_types_at_info_if_different(first, second) + first = safe_str(first) + second = safe_str(second) + if ignore_case: + first = first.lower() + second = second.lower() + if strip_spaces: + first = self._strip_spaces(first, strip_spaces) + second = self._strip_spaces(second, strip_spaces) + if collapse_spaces: + first = self._collapse_spaces(first) + second = self._collapse_spaces(second) + self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors) def should_not_start_with( self, @@ -1043,6 +1126,24 @@ def should_not_start_with( self._get_string_msg(str1, str2, msg, values, "starts with") ) + def should_not_start_with_fuzzy(self, str1, str2, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + if ignore_case: + str1 = str1.lower() + str2 = str2.lower() + if strip_spaces: + str1 = self._strip_spaces(str1, strip_spaces) + str2 = self._strip_spaces(str2, strip_spaces) + if collapse_spaces: + str1 = self._collapse_spaces(str1) + str2 = self._collapse_spaces(str2) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + if matched is not None: + if matched.start < 1: + raise AssertionError(self._get_string_msg(str1, str2, msg, values, + 'starts with')) + def should_start_with( self, str1, @@ -1073,6 +1174,26 @@ def should_start_with( self._get_string_msg(str1, str2, msg, values, "does not start with") ) + + def should_start_with_fuzzy(self, str1, str2, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + if ignore_case: + str1 = str1.lower() + str2 = str2.lower() + if strip_spaces: + str1 = self._strip_spaces(str1, strip_spaces) + str2 = self._strip_spaces(str2, strip_spaces) + if collapse_spaces: + str1 = self._collapse_spaces(str1) + str2 = self._collapse_spaces(str2) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + if matched is None: + if matched.start < 1: + raise AssertionError(self._get_string_msg(str1, str2, msg, values, + 'does not start with')) + + def should_not_end_with( self, str1, @@ -1103,6 +1224,24 @@ def should_not_end_with( self._get_string_msg(str1, str2, msg, values, "ends with") ) + def should_not_end_with_fuzzy(self, str1, str2, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + if ignore_case: + str1 = str1.lower() + str2 = str2.lower() + if strip_spaces: + str1 = self._strip_spaces(str1, strip_spaces) + str2 = self._strip_spaces(str2, strip_spaces) + if collapse_spaces: + str1 = self._collapse_spaces(str1) + str2 = self._collapse_spaces(str2) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + if matched is None: + if matched.end == len(str1): + raise AssertionError(self._get_string_msg(str1, str2, msg, values, + 'ends with')) + def should_end_with( self, str1, @@ -1129,10 +1268,29 @@ def should_end_with( str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) if not str1.endswith(str2): + raise AssertionError( self._get_string_msg(str1, str2, msg, values, "does not end with") ) + def should_end_with_fuzzy(self, str1, str2, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + if ignore_case: + str1 = str1.lower() + str2 = str2.lower() + if strip_spaces: + str1 = self._strip_spaces(str1, strip_spaces) + str2 = self._strip_spaces(str2, strip_spaces) + if collapse_spaces: + str1 = self._collapse_spaces(str1) + str2 = self._collapse_spaces(str2) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + if matched is None: + if matched.end != len(str1): + raise AssertionError(self._get_string_msg(str1, str2, msg, values, + 'does not end with')) + def should_not_contain( self, container, @@ -1200,6 +1358,33 @@ def should_not_contain( self._get_string_msg(orig_container, item, msg, values, "contains") ) + def should_not_contain_fuzzy(self, container, item, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + orig_container = container + if ignore_case and isinstance(item, str): + item = item.lower() + if isinstance(container, str): + container = container.lower() + elif is_list_like(container): + container = set(x.lower() if isinstance(x, str) else x for x in container) + if strip_spaces and isinstance(item, str): + item = self._strip_spaces(item, strip_spaces) + if isinstance(container, str): + container = self._strip_spaces(container, strip_spaces) + elif is_list_like(container): + container = set(self._strip_spaces(x, strip_spaces) for x in container) + if collapse_spaces and isinstance(item, str): + item = self._collapse_spaces(item) + if isinstance(container, str): + container = self._collapse_spaces(container) + elif is_list_like(container): + container = set(self._collapse_spaces(x) for x in container) + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + if matched is not None: + raise AssertionError(self._get_string_msg(orig_container, item, msg, + values, 'contains')) + def should_contain( self, container, @@ -1286,6 +1471,33 @@ def should_contain( ) ) + def should_contain_fuzzy(self, container, item, msg=None, values=True, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + orig_container = container + if ignore_case and isinstance(item, str): + item = item.lower() + if isinstance(container, str): + container = container.lower() + elif is_list_like(container): + container = set(x.lower() if isinstance(x, str) else x for x in container) + if strip_spaces and isinstance(item, str): + item = self._strip_spaces(item, strip_spaces) + if isinstance(container, str): + container = self._strip_spaces(container, strip_spaces) + elif is_list_like(container): + container = set(self._strip_spaces(x, strip_spaces) for x in container) + if collapse_spaces and isinstance(item, str): + item = self._collapse_spaces(item) + if isinstance(container, str): + container = self._collapse_spaces(container) + elif is_list_like(container): + container = set(self._collapse_spaces(x) for x in container) + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + if matched is None: + raise AssertionError(self._get_string_msg(orig_container, item, msg, + values, 'does not contain')) + def should_contain_any( self, container, @@ -1348,6 +1560,51 @@ def should_contain_any( ) ) + def should_contain_any_fuzzy(self, container, *items, **configuration): + msg = configuration.pop('msg', None) + values = configuration.pop('values', True) + ignore_case = is_truthy(configuration.pop('ignore_case', False)) + strip_spaces = configuration.pop('strip_spaces', False) + percent_match = configuration.pop('percent_match', None) + max_errors = configuration.pop('max_errors', None) + collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) + if configuration: + raise RuntimeError("Unsupported configuration parameter%s: %s." + % (s(configuration), seq2str(sorted(configuration)))) + if not items: + raise RuntimeError('One or more items required.') + orig_container = container + if ignore_case: + items = [x.lower() if isinstance(x, str) else x for x in items] + if isinstance(container, str): + container = container.lower() + elif is_list_like(container): + container = set(x.lower() if isinstance(x, str) else x for x in container) + if strip_spaces: + items = [self._strip_spaces(x, strip_spaces) for x in items] + if isinstance(container, str): + container = self._strip_spaces(container, strip_spaces) + elif is_list_like(container): + container = set(self._strip_spaces(x, strip_spaces) for x in container) + if collapse_spaces: + items = [self._collapse_spaces(x) for x in items] + if isinstance(container, str): + container = self._collapse_spaces(container) + elif is_list_like(container): + container = set(self._collapse_spaces(x) for x in container) + + for item in items: + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + if matched is not None: + return + + msg = self._get_string_msg(orig_container, + seq2str(items, lastsep=' or '), + msg, values, + 'does not contain any of', + quote_item2=False) + raise AssertionError(msg) + def should_not_contain_any( self, container, @@ -1410,6 +1667,49 @@ def should_not_contain_any( ) ) + def should_not_contain_any_fuzzy(self, container, *items, **configuration): + msg = configuration.pop('msg', None) + values = configuration.pop('values', True) + ignore_case = is_truthy(configuration.pop('ignore_case', False)) + strip_spaces = configuration.pop('strip_spaces', False) + percent_match = configuration.pop('percent_match', None) + max_errors = configuration.pop('max_errors', None) + collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) + if configuration: + raise RuntimeError("Unsupported configuration parameter%s: %s." + % (s(configuration), seq2str(sorted(configuration)))) + if not items: + raise RuntimeError('One or more items required.') + orig_container = container + if ignore_case: + items = [x.lower() if isinstance(x, str) else x for x in items] + if isinstance(container, str): + container = container.lower() + elif is_list_like(container): + container = set(x.lower() if isinstance(x, str) else x for x in container) + if strip_spaces: + items = [self._strip_spaces(x, strip_spaces) for x in items] + if isinstance(container, str): + container = self._strip_spaces(container, strip_spaces) + elif is_list_like(container): + container = set(self._strip_spaces(x, strip_spaces) for x in container) + if collapse_spaces: + items = [self._collapse_spaces(x) for x in items] + if isinstance(container, str): + container = self._collapse_spaces(container) + elif is_list_like(container): + container = set(self._collapse_spaces(x) for x in container) + + for item in items: + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + if matched is not None: + msg = self._get_string_msg(orig_container, + seq2str(items, lastsep=' or '), + msg, values, + 'contains one or more of', + quote_item2=False) + raise AssertionError(msg) + def should_contain_x_times( self, container, @@ -1479,6 +1779,37 @@ def should_contain_x_times( ) self.should_be_equal_as_integers(x, count, msg, values=False) + def should_contain_x_times_fuzzy(self, container, item, count, msg=None, + ignore_case=False, strip_spaces=False, + collapse_spaces=False, percent_match=None, max_errors=None): + count = self._convert_to_integer(count) + orig_container = container + if isinstance(item, str): + if ignore_case: + item = item.lower() + if isinstance(container, str): + container = container.lower() + elif is_list_like(container): + container = [x.lower() if isinstance(x, str) else x for x in container] + if strip_spaces: + item = self._strip_spaces(item, strip_spaces) + if isinstance(container, str): + container = self._strip_spaces(container, strip_spaces) + elif is_list_like(container): + container = [self._strip_spaces(x, strip_spaces) for x in container] + if collapse_spaces: + item = self._collapse_spaces(item) + if isinstance(container, str): + container = self._collapse_spaces(container) + elif is_list_like(container): + container = [self._collapse_spaces(x) for x in container] + matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors) + x = len(matches) + if not msg: + msg = "%r contains '%s' %d time%s, not %d time%s." \ + % (orig_container, item, x, s(x), count, s(count)) + self.should_be_equal_as_integers(x, count, msg, values=False) + def get_count(self, container, item): """Returns and logs how many times ``item`` is found from ``container``. @@ -1500,6 +1831,19 @@ def get_count(self, container, item): self.log(f"Item found from container {count} time{s(count)}.") return count + def get_count_fuzzy(self, container, item, percent_match=None, max_errors=None): + if not hasattr(container, 'count'): + try: + container = list(container) + except: + raise RuntimeError("Converting '%s' to list failed: %s" + % (container, get_error_message())) + matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors) + count = len(matches) + self.log('Item found from container %d time%s.' % (count, s(count))) + return count + + def should_not_match( self, string, @@ -1525,6 +1869,13 @@ def should_not_match( self._get_string_msg(string, pattern, msg, values, "matches") ) + def should_not_match_fuzzy(self, string, pattern, msg=None, values=True, + ignore_case=False, percent_match=None, max_errors=None): + matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors) + if matched is not None: + raise AssertionError(self._get_string_msg(string, pattern, msg, + values, 'matches')) + def should_match(self, string, pattern, msg=None, values=True, ignore_case=False): """Fails if the given ``string`` does not match the given ``pattern``. @@ -1544,6 +1895,13 @@ def should_match(self, string, pattern, msg=None, values=True, ignore_case=False self._get_string_msg(string, pattern, msg, values, "does not match") ) + def should_match_fuzzy(self, string, pattern, msg=None, values=True, + ignore_case=False, percent_match=None, max_errors=None): + matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors) + if matched is None: + raise AssertionError(self._get_string_msg(string, pattern, msg, + values, 'matches')) + def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None): """Fails if ``string`` does not match ``pattern`` as a regular expression. From 62394c7c32355cca83f74d57471323be86abeb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 1 Aug 2025 15:21:44 +0200 Subject: [PATCH 06/25] utest/requirements.txt: Add fuzzysearch requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- utest/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/utest/requirements.txt b/utest/requirements.txt index 3fa41be15d7..f2de51298cb 100644 --- a/utest/requirements.txt +++ b/utest/requirements.txt @@ -3,3 +3,4 @@ docutils >= 0.10 jsonschema typing_extensions >= 4.13 +fuzzysearch==0.8.0 From eadddbe99acee1522ae67cb5cbdbaaeaa973077d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Tue, 5 Aug 2025 06:06:04 +0200 Subject: [PATCH 07/25] Collections.py: Add get_index_from_list_fuzzy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Collections.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 8711ec63bc9..996642054e0 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -20,7 +20,7 @@ from robot.api import logger from robot.utils import ( is_dict_like, is_list_like, Matcher, NotSet, plural_or_not as s, seq2str, seq2str2, - type_name + type_name, fuzzy ) from robot.utils.asserts import assert_equal from robot.version import get_version @@ -263,6 +263,30 @@ def get_index_from_list(self, list_, value, start=0, end=None): return start + list_.index(value) except ValueError: return -1 + + def get_index_from_list_fuzzy(self, list_, value, start=0, end=None, percent_match=None, max_errors=None): + """Returns the index of the first occurrence of the ``value`` on the list. + + The search can be narrowed to the selected sublist by the ``start`` and + ``end`` indexes having the same semantics as with `Get Slice From List` + keyword. In case the value is not found, -1 is returned. The given list + is never altered by this keyword. + + Example: + | ${x} = | Get Index From List | ${L5} | d | + => + | ${x} = 3 + | ${L5} is not changed + """ + self._validate_list(list_) + start = self._index_to_int(start, empty_to_zero=True) + list_ = self.get_slice_from_list(list_, start, end) + try: + for idx, item in enumerate(list_): + if fuzzy.fuzzy_find(item, value, percent_match, max_errors): + return start + idx + except ValueError: + return -1 def copy_list(self, list_, deepcopy=False): """Returns a copy of the given list. From 84a2475a819a3c2107d5b6ce2e5868d9666ac803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Tue, 5 Aug 2025 07:18:33 +0200 Subject: [PATCH 08/25] Telnet.py: Export Read Until Fuzzy keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 4b9bd0dccfe..8914d34c59a 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -363,7 +363,7 @@ def _get_connection_keywords(self): excluded = [ name for name in dir(telnetlib.Telnet()) - if name not in ["write", "read", "read_until"] + if name not in ["write", "read", "read_until", "read_until_fuzzy"] ] self._conn_kws = self._get_keywords(conn, excluded) return self._conn_kws From 904a0c54057b7675b661723c451909e08cb60ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Tue, 5 Aug 2025 07:25:15 +0200 Subject: [PATCH 09/25] Telnet.py: Fix missing args to terminal_reauntil_fuzzy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 8914d34c59a..267f56bc4aa 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -1008,7 +1008,7 @@ def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, loglev def _read_until_fuzzy(self, expected, percent_match=None, max_errors=None): self._verify_connection() if self._terminal_emulator: - return self._terminal_read_until_fuzzy(expected) + return self._terminal_read_until_fuzzy(expected, percent_match, max_errors) expected = self._encode(expected) output = telnetlib.Telnet.read_until_fuzzy(self, expected, self._timeout, percent_match, max_errors) found = fuzzy.fuzzy_find(output, expected, percent_match, max_errors) is not None From 549fe94abba596ae22110de988faa8904092155e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Tue, 5 Aug 2025 08:18:02 +0200 Subject: [PATCH 10/25] Collections.py: Add List Should Contain Fuzzy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Collections.py | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 996642054e0..02a1702b54b 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -345,6 +345,22 @@ def list_should_contain_value(self, list_, value, msg=None, ignore_case=False): msg, ) + def list_should_contain_value_fuzzy(self, list_, value, msg=None, ignore_case=False, percent_match=None, max_errors=None): + self._validate_list(list_) + normalize = Normalizer(ignore_case).normalize + v = normalize(value) + l = normalize(list_) + found = False + for item in l: + if fuzzy.fuzzy_find(item, v, percent_match, max_errors) is not None: + found=True + break + _verify_condition( + found, + f"{seq2str2(list_)} does not contain value '{value}'.", + msg, + ) + def list_should_not_contain_value(self, list_, value, msg=None, ignore_case=False): """Fails if the ``value`` is found from ``list``. @@ -361,6 +377,22 @@ def list_should_not_contain_value(self, list_, value, msg=None, ignore_case=Fals f"{seq2str2(list_)} contains value '{value}'.", msg, ) + + def list_should_not_contain_value_fuzzy(self, list_, value, msg=None, ignore_case=False, percent_match=None, max_errors=None): + self._validate_list(list_) + normalize = Normalizer(ignore_case).normalize + v = normalize(value) + l = normalize(list_) + found = False + for item in l: + if fuzzy.fuzzy_find(item, v, percent_match, max_errors): + found=True + break + _verify_condition( + not found, + f"{seq2str2(list_)} contains value '{value}'.", + msg, + ) def list_should_not_contain_duplicates(self, list_, msg=None, ignore_case=False): """Fails if any element in the ``list`` is found from it more than once. From 9c42c5de23dc168834abe9588b2a9bac4b609969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Wed, 6 Aug 2025 07:28:19 +0200 Subject: [PATCH 11/25] Telnet.py: Add keyword decorator to new keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 267f56bc4aa..0588611adcb 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -985,20 +985,8 @@ def read_until(self, expected, loglevel=None): raise NoMatchError(expected, self._timeout, output) return output + @keyword def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, loglevel=None): - """Reads output until ``expected`` text is matched using fuzzy comparison. - - Text up to and including the match is returned and logged. If no match - is found, this keyword fails. How much to wait for the output depends - on the [#Configuration|configured timeout]. - - Set `percent_match` to match strings where `percent_match` of characters are correct - Set `max_errors` to match strings, which differ at a maximum of `max_errors` characters - `max_errors` overrides `percent_match` when both are set. - - See `Logging` section for more information about log levels. Use - `Read Until Regexp` if more complex matching is needed. - """ success, output = self._read_until_fuzzy(expected, percent_match, max_errors) self._log(output, loglevel) if not success: From f03193ca4b82d8271c1547d580668f73635503ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Wed, 6 Aug 2025 10:41:09 +0200 Subject: [PATCH 12/25] Telnet.py: Add _get_keywords debug prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 0588611adcb..76ca1ba72c3 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -345,9 +345,13 @@ def _get_library_keywords(self): return self._lib_kws def _get_keywords(self, source, excluded): - return [ + logger.warn(dir(source)) + logger.warn(excluded) + kwds = [ name for name in dir(source) if self._is_keyword(name, source, excluded) ] + logger.warn(kwds) + return kwds def _is_keyword(self, name, source, excluded): return ( From 5da534d59739357447879cc7de375869251da06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:31:49 +0200 Subject: [PATCH 13/25] utils/fuzzy.py: Add max_insertions and max_deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/fuzzy.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py index 0313abc9362..2e7927458e2 100644 --- a/src/robot/utils/fuzzy.py +++ b/src/robot/utils/fuzzy.py @@ -1,15 +1,19 @@ import fuzzysearch -def fuzzy_find(buffer, expected, percent_match=None, max_errors=None, ignore_case=False): - found = fuzzy_find_all(buffer, expected, percent_match, max_errors, ignore_case) +def fuzzy_find(buffer, expected, percent_match=None, max_errors=None, max_insertions:int=None, max_deletions:int=None, ignore_case=False): + found = fuzzy_find_all(buffer, expected, percent_match, max_errors, max_insertions, max_deletions, ignore_case) if len(found) > 0: return found[0] return None -def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=None, ignore_case:bool=False): +def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=None, max_insertions:int=None, max_deletions:int=None, ignore_case:bool=False): + if max_insertions: + max_insertions=int(max_insertions) + if max_deletions: + max_deletions=int(max_deletions) if max_errors is not None: max_errors = int(max_errors) max_l_dist = max_errors @@ -23,10 +27,10 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No if ignore_case: - matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist) + matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) # change matched to contain original, possibly uppercase, input for match in matches: match.matched = buffer[match.start:match.end] else: - matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist) + matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) return matches From ff6b663b67d8828d43d70e7d1c0f5ca9a57e8f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:32:18 +0200 Subject: [PATCH 14/25] libraries/Telnet.py: Add max_insertions and max_deletions to fuzzy methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 76ca1ba72c3..5cdc1fb1272 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -990,20 +990,20 @@ def read_until(self, expected, loglevel=None): return output @keyword - def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, loglevel=None): - success, output = self._read_until_fuzzy(expected, percent_match, max_errors) + def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, loglevel=None): + success, output = self._read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) self._log(output, loglevel) if not success: raise NoMatchError(expected, self._timeout, output) return output - def _read_until_fuzzy(self, expected, percent_match=None, max_errors=None): + def _read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): self._verify_connection() if self._terminal_emulator: - return self._terminal_read_until_fuzzy(expected, percent_match, max_errors) + return self._terminal_read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) expected = self._encode(expected) - output = telnetlib.Telnet.read_until_fuzzy(self, expected, self._timeout, percent_match, max_errors) - found = fuzzy.fuzzy_find(output, expected, percent_match, max_errors) is not None + output = telnetlib.Telnet.read_until_fuzzy(self, expected, self._timeout, percent_match, max_errors, max_insertions, max_deletions) + found = fuzzy.fuzzy_find(output, expected, percent_match, max_errors, max_insertions, max_deletions) is not None return found, self._decode(output) def _read_until(self, expected): @@ -1033,16 +1033,16 @@ def _terminal_read_until(self, expected): return True, output return False, self._terminal_emulator.read() - def _terminal_read_until_fuzzy(self, expected, percent_match=None, max_errors=None): + def _terminal_read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): max_time = time.time() + self._timeout - output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors) + output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) if output: return True, output while time.time() < max_time: output = telnetlib.Telnet.read_until_fuzzy(self, self._encode(expected), - self._terminal_frequency, percent_match, max_errors) + self._terminal_frequency, percent_match, max_errors, max_insertions, max_deletions) self._terminal_emulator.feed(self._decode(output)) - output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors) + output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) if output: return True, output return False, self._terminal_emulator.read() @@ -1348,10 +1348,10 @@ def read_until(self, expected): return current_out[: exp_index + len(expected)] return None - def read_until_fuzzy(self, expected, percent_match=None, max_errors=None): + def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): current_out = self.current_output - match = fuzzy.fuzzy_find(current_out, expected, percent_match, max_errors) + match = fuzzy.fuzzy_find(current_out, expected, percent_match, max_errors, max_insertions, max_deletions) if match is None: return None From 7132b106c7a9bed9c243fe5e5510592e34229cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:33:24 +0200 Subject: [PATCH 15/25] libraries/Strings.py: Add max_insertions and max_deletions to fuzzy methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/String.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index e846cc00ddc..cbd7adcfbe4 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -289,7 +289,7 @@ def get_line_number_containing_string(self, string, pattern, case_insensitive=Fa ret = 0 return ret - def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match=None, max_errors=None, case_insensitive=False): + def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, case_insensitive=False): """Returns line number of the given ``string`` that contain the ``pattern``. The ``pattern`` is always considered to be a normal string, not a glob or regexp pattern. A line matches if the ``pattern`` is found anywhere @@ -302,7 +302,7 @@ def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match If multiple line match only line number of first occurrence is returned. """ for n,l in enumerate(string.splitlines()): - matches = fuzzy.fuzzy_find(l, pattern, percent_match, max_errors, ignore_case=case_insensitive) + matches = fuzzy.fuzzy_find(l, pattern, percent_match, max_errors, max_insertions, max_deletions, ignore_case=case_insensitive) if len(matches) > 0: return n return 0 @@ -388,8 +388,8 @@ def get_lines_matching_pattern( matches = lambda line: fnmatchcase(line, pattern) return self._get_matching_lines(string, matches) - def get_lines_matching_fuzzy(self, string, pattern, percent_match=None, max_errors=None, case_insensitive=False): - matches = lambda line: len(fuzzy.fuzzy_find(line.lower(), pattern.lower(), percent_match, max_errors, ignore_case=case_insensitive)) > 0 + def get_lines_matching_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, case_insensitive=False): + matches = lambda line: len(fuzzy.fuzzy_find(line.lower(), pattern.lower(), percent_match, max_errors, max_insertions, max_deletions, ignore_case=case_insensitive)) > 0 return self._get_matching_lines(string, matches) def get_lines_matching_regexp( From 152f562700747bc95a5ff11d0cb92c500f318064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:34:38 +0200 Subject: [PATCH 16/25] util/asserts.py: Add max_insertions and max_deletions to fuzzy methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/asserts.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/utils/asserts.py b/src/robot/utils/asserts.py index 95d8c68a37d..965bd2d411d 100644 --- a/src/robot/utils/asserts.py +++ b/src/robot/utils/asserts.py @@ -180,15 +180,15 @@ def assert_equal(first, second, msg=None, values=True, formatter=safe_str): if not first == second: # noqa: SIM201 _report_inequality(first, second, "!=", msg, values, formatter) -def assert_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None): +def assert_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None, max_insertions=None, max_deletions=None): """Fail if given objects are unequal as determined by fuzzy comparison. Default is 90% similarity.""" - match = fuzzy._fuzzy_find(first, second, percent_match, max_errors) + match = fuzzy._fuzzy_find(first, second, percent_match, max_errors, max_insertions, max_deletions) if not len(match) > 0: _report_inequality(first, second, '!=', msg, values, formatter) -def assert_not_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None): +def assert_not_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None, max_insertions=None, max_deletions=None): """Fail if given objects are equal as determined by fuzzy comparison. Default is 90% similarity.""" - match = fuzzy._fuzzy_find(first, second, percent_match, max_errors) + match = fuzzy._fuzzy_find(first, second, percent_match, max_errors, max_insertions, max_deletions) if len(match) > 0: _report_inequality(first, second, '!=', msg, values, formatter) From 6a5406d0531ded54e2ab508f290e3f6f5760bcd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:40:42 +0200 Subject: [PATCH 17/25] BuiltIn.py: Add max_insertions and max_deletions to fuzzy methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/BuiltIn.py | 78 ++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 07fdeb4467e..02819b398b8 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -106,8 +106,8 @@ def _matches(self, string, pattern, caseless=False): matcher = Matcher(pattern, caseless=caseless, spaceless=False) return matcher.match(string) - def _matches_fuzzy(self, string, pattern, percent_match=None, max_errors=None, caseless=False): - matches = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors) + def _matches_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, caseless=False): + matches = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors, max_insertions, max_deletions) return matches is not None def _is_true(self, condition): @@ -712,7 +712,7 @@ def _should_be_equal(self, first, second, msg, values, formatter="str"): def should_be_equal_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, formatter='str', strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) if isinstance(first, str) and isinstance(second, str): if ignore_case: @@ -724,16 +724,16 @@ def should_be_equal_fuzzy(self, first, second, msg=None, values=True, if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors) + self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors, max_insertions, max_deletions) - def _should_be_equal_fuzzy(self, first, second, msg, values, formatter='str', percent_match=None, max_errors=None): + def _should_be_equal_fuzzy(self, first, second, msg, values, formatter='str', percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): include_values = self._include_values(values) formatter = self._get_formatter(formatter) if first == second: return if include_values and isinstance(first, str) and isinstance(second, safe_str): self._raise_multi_diff(first, second, msg, formatter) - assert_equal_fuzzy(first, second, msg, include_values, formatter, percent_match, max_errors) + assert_equal_fuzzy(first, second, msg, include_values, formatter, percent_match, max_errors, max_insertions, max_deletions) def _log_types_at_info_if_different(self, first, second): level = "DEBUG" if type(first) is type(second) else "INFO" @@ -823,7 +823,7 @@ def should_not_be_equal( def should_not_be_equal_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, match_percent=None, max_errors=None): + collapse_spaces=False, match_percent=None, max_errors=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) if isinstance(first, str) and isinstance(second, str): if ignore_case: @@ -835,13 +835,13 @@ def should_not_be_equal_fuzzy(self, first, second, msg=None, values=True, if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_not_be_equal_fuzzy(first, second, msg, values, match_percent=None, max_errors=None) + self._should_not_be_equal_fuzzy(first, second, msg, values, match_percent, max_errors, max_insertions, max_deletions) def _should_not_be_equal(self, first, second, msg, values): assert_not_equal(first, second, msg, self._include_values(values)) - def _should_not_be_equal_fuzzy(self, first, second, msg, values, match_percent, max_errors): - assert_not_equal_fuzzy(first, second, msg, self._include_values(values), match_percent, max_errors) + def _should_not_be_equal_fuzzy(self, first, second, msg, values, match_percent, max_errors, max_insertions=None, max_deletions=None): + assert_not_equal_fuzzy(first, second, msg, self._include_values(values), match_percent, max_errors, max_insertions, max_deletions) def should_not_be_equal_as_integers( self, @@ -1015,7 +1015,7 @@ def should_not_be_equal_as_strings( def should_not_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) first = safe_str(first) second = safe_str(second) @@ -1028,7 +1028,7 @@ def should_not_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=T if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_not_be_equal_fuzzy(first, second, msg, values, percent_match, max_errors) + self._should_not_be_equal_fuzzy(first, second, msg, values, percent_match, max_errors, max_insertions, max_deletions) def should_be_equal_as_strings( self, @@ -1079,9 +1079,9 @@ def should_be_equal_as_strings( second = self._collapse_spaces(second) self._should_be_equal(first, second, msg, values, formatter) - def should_be_equal_as_strings(self, first, second, msg=None, values=True, + def should_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, strip_spaces=False, - formatter='str', collapse_spaces=False, percent_match=None, max_errors=None): + formatter='str', collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) first = safe_str(first) second = safe_str(second) @@ -1094,7 +1094,7 @@ def should_be_equal_as_strings(self, first, second, msg=None, values=True, if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors) + self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors, max_insertions, max_deletions) def should_not_start_with( self, @@ -1128,7 +1128,7 @@ def should_not_start_with( def should_not_start_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1138,7 +1138,7 @@ def should_not_start_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) if matched is not None: if matched.start < 1: raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1177,7 +1177,7 @@ def should_start_with( def should_start_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1187,7 +1187,7 @@ def should_start_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) if matched is None: if matched.start < 1: raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1226,7 +1226,7 @@ def should_not_end_with( def should_not_end_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1236,7 +1236,7 @@ def should_not_end_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) if matched is None: if matched.end == len(str1): raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1275,7 +1275,7 @@ def should_end_with( def should_end_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1285,7 +1285,7 @@ def should_end_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors) + matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) if matched is None: if matched.end != len(str1): raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1360,7 +1360,7 @@ def should_not_contain( def should_not_contain_fuzzy(self, container, item, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): orig_container = container if ignore_case and isinstance(item, str): item = item.lower() @@ -1380,7 +1380,7 @@ def should_not_contain_fuzzy(self, container, item, msg=None, values=True, container = self._collapse_spaces(container) elif is_list_like(container): container = set(self._collapse_spaces(x) for x in container) - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) if matched is not None: raise AssertionError(self._get_string_msg(orig_container, item, msg, values, 'contains')) @@ -1473,7 +1473,7 @@ def should_contain( def should_contain_fuzzy(self, container, item, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): orig_container = container if ignore_case and isinstance(item, str): item = item.lower() @@ -1493,7 +1493,7 @@ def should_contain_fuzzy(self, container, item, msg=None, values=True, container = self._collapse_spaces(container) elif is_list_like(container): container = set(self._collapse_spaces(x) for x in container) - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) if matched is None: raise AssertionError(self._get_string_msg(orig_container, item, msg, values, 'does not contain')) @@ -1567,6 +1567,8 @@ def should_contain_any_fuzzy(self, container, *items, **configuration): strip_spaces = configuration.pop('strip_spaces', False) percent_match = configuration.pop('percent_match', None) max_errors = configuration.pop('max_errors', None) + max_insertions = configuration.pop('max_insertions', None) + max_deletions = configuration.pop('max_deletions', None) collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) if configuration: raise RuntimeError("Unsupported configuration parameter%s: %s." @@ -1594,7 +1596,7 @@ def should_contain_any_fuzzy(self, container, *items, **configuration): container = set(self._collapse_spaces(x) for x in container) for item in items: - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) if matched is not None: return @@ -1674,6 +1676,8 @@ def should_not_contain_any_fuzzy(self, container, *items, **configuration): strip_spaces = configuration.pop('strip_spaces', False) percent_match = configuration.pop('percent_match', None) max_errors = configuration.pop('max_errors', None) + max_insertions = configuration.pop('max_insertions', None) + max_deletions = configuration.pop('max_deletions', None) collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) if configuration: raise RuntimeError("Unsupported configuration parameter%s: %s." @@ -1701,7 +1705,7 @@ def should_not_contain_any_fuzzy(self, container, *items, **configuration): container = set(self._collapse_spaces(x) for x in container) for item in items: - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors) + matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) if matched is not None: msg = self._get_string_msg(orig_container, seq2str(items, lastsep=' or '), @@ -1781,7 +1785,7 @@ def should_contain_x_times( def should_contain_x_times_fuzzy(self, container, item, count, msg=None, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None): + collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): count = self._convert_to_integer(count) orig_container = container if isinstance(item, str): @@ -1803,7 +1807,7 @@ def should_contain_x_times_fuzzy(self, container, item, count, msg=None, container = self._collapse_spaces(container) elif is_list_like(container): container = [self._collapse_spaces(x) for x in container] - matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors) + matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors, max_insertions, max_deletions) x = len(matches) if not msg: msg = "%r contains '%s' %d time%s, not %d time%s." \ @@ -1831,14 +1835,14 @@ def get_count(self, container, item): self.log(f"Item found from container {count} time{s(count)}.") return count - def get_count_fuzzy(self, container, item, percent_match=None, max_errors=None): + def get_count_fuzzy(self, container, item, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): if not hasattr(container, 'count'): try: container = list(container) except: raise RuntimeError("Converting '%s' to list failed: %s" % (container, get_error_message())) - matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors) + matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors, max_insertions, max_deletions) count = len(matches) self.log('Item found from container %d time%s.' % (count, s(count))) return count @@ -1870,8 +1874,8 @@ def should_not_match( ) def should_not_match_fuzzy(self, string, pattern, msg=None, values=True, - ignore_case=False, percent_match=None, max_errors=None): - matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors) + ignore_case=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors, max_insertions, max_deletions) if matched is not None: raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'matches')) @@ -1896,8 +1900,8 @@ def should_match(self, string, pattern, msg=None, values=True, ignore_case=False ) def should_match_fuzzy(self, string, pattern, msg=None, values=True, - ignore_case=False, percent_match=None, max_errors=None): - matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors) + ignore_case=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors, max_insertions, max_deletions) if matched is None: raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'matches')) From d18d73828c9d6f1a160716a03be5bce7b1bd59ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:43:40 +0200 Subject: [PATCH 18/25] String.py: Add max_insertions and max_deletions to fuzzy methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/String.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index cbd7adcfbe4..a91b6c10a0d 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -290,7 +290,7 @@ def get_line_number_containing_string(self, string, pattern, case_insensitive=Fa return ret def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, case_insensitive=False): - """Returns line number of the given ``string`` that contain the ``pattern``. + """Returns line number of the given ``string`` that contain the ``pattern``.` The ``pattern`` is always considered to be a normal string, not a glob or regexp pattern. A line matches if the ``pattern`` is found anywhere on it. From a17d7235218aa7c923bf1a5f3ce2600574eee30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:51:50 +0200 Subject: [PATCH 19/25] utils/fuzzy.py: Add exception logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/fuzzy.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py index 2e7927458e2..d7f31986072 100644 --- a/src/robot/utils/fuzzy.py +++ b/src/robot/utils/fuzzy.py @@ -1,5 +1,6 @@ import fuzzysearch +from robot.api import logger def fuzzy_find(buffer, expected, percent_match=None, max_errors=None, max_insertions:int=None, max_deletions:int=None, ignore_case=False): found = fuzzy_find_all(buffer, expected, percent_match, max_errors, max_insertions, max_deletions, ignore_case) @@ -25,12 +26,17 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No else: max_l_dist = None - - if ignore_case: - matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) - # change matched to contain original, possibly uppercase, input - for match in matches: - match.matched = buffer[match.start:match.end] - else: - matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) + try: + if ignore_case: + matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) + # change matched to contain original, possibly uppercase, input + for match in matches: + match.matched = buffer[match.start:match.end] + else: + matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) + except Exception as e: + logger.error(e) + logger.error("\n\n\nbuffer:") + logger.error(buffer) + logger.error("\n\n\nexpected:") return matches From 1ff1ce29db15368d11cd7669fe7b629d0c00b1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 8 Aug 2025 11:53:20 +0200 Subject: [PATCH 20/25] Telnet.py: Remove debug prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/libraries/Telnet.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 5cdc1fb1272..9e8d7d917d9 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -345,12 +345,9 @@ def _get_library_keywords(self): return self._lib_kws def _get_keywords(self, source, excluded): - logger.warn(dir(source)) - logger.warn(excluded) kwds = [ name for name in dir(source) if self._is_keyword(name, source, excluded) ] - logger.warn(kwds) return kwds def _is_keyword(self, name, source, excluded): From c1d19932e4a6693d091d1dc4e89fc34894f8d936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Wed, 20 Aug 2025 08:24:37 +0200 Subject: [PATCH 21/25] fuzzy.py: Add arguments debug prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/fuzzy.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py index d7f31986072..27ac775d763 100644 --- a/src/robot/utils/fuzzy.py +++ b/src/robot/utils/fuzzy.py @@ -26,6 +26,11 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No else: max_l_dist = None + print("percent_match: ", percent_match, ": ", type(percent_match)) + print("max_errors: ", max_errors, ": ", type(max_errors)) + print("max_insertions: ", max_insertions, ": ", type(max_insertions)) + print("max_deletions: ", max_deletions, ": ", type(max_deletions)) + print("ignore_case: ", ignore_case, ": ", type(ignore_case)) try: if ignore_case: matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) @@ -34,9 +39,10 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No match.matched = buffer[match.start:match.end] else: matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) + return matches except Exception as e: logger.error(e) logger.error("\n\n\nbuffer:") logger.error(buffer) logger.error("\n\n\nexpected:") - return matches + From 16264f78f2e7bb25a376f0090796c301a5ebbc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Wed, 20 Aug 2025 09:03:59 +0200 Subject: [PATCH 22/25] fuzzy.py: Add robust type checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/fuzzy.py | 43 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py index 27ac775d763..9d62023f9e8 100644 --- a/src/robot/utils/fuzzy.py +++ b/src/robot/utils/fuzzy.py @@ -10,21 +10,38 @@ def fuzzy_find(buffer, expected, percent_match=None, max_errors=None, max_insert return None def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=None, max_insertions:int=None, max_deletions:int=None, ignore_case:bool=False): - - if max_insertions: - max_insertions=int(max_insertions) - if max_deletions: - max_deletions=int(max_deletions) + max_l_dist = 0 + + if max_insertions is not None: + try: + max_insertions=int(max_insertions) + except: + logger.warn(f"max_insertions parameter invalid: {max_insertions}:{type(max_insertions)}") + max_insertions=None + + if max_deletions is not None: + try: + max_deletions=int(max_deletions) + except: + logger.warn(f"max_deletions parameter invalid: {max_deletions}:{type(max_deletions)}") + max_deletions=None + if max_errors is not None: - max_errors = int(max_errors) - max_l_dist = max_errors + try: + max_errors = int(max_errors) + max_l_dist = max_errors + except: + logger.warn(f"max_errors parameter invalid: {max_errors}:{type(max_errors)}") + max_errors=None elif percent_match is not None: - percent_match = float(percent_match) - percent_match = min(100, max(0, percent_match)) # limit to 0-100 - percent_errors = 100 - percent_match - max_l_dist = round(len(expected) * percent_errors / 100.0) - else: - max_l_dist = None + try: + percent_match = float(percent_match) + percent_match = min(100, max(0, percent_match)) # limit to 0-100 + percent_errors = 100 - percent_match + max_l_dist = int(round(len(expected) * percent_errors / 100.0)) + except: + logger.warn(f"percent_match parameter invalid: {percent_match}:{type(percent_match)}") + percent_match=None print("percent_match: ", percent_match, ": ", type(percent_match)) print("max_errors: ", max_errors, ": ", type(max_errors)) From 4da93513dcec495e34445151f62246782cd889d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Wed, 20 Aug 2025 09:41:16 +0200 Subject: [PATCH 23/25] fuzzy.py: print logs only on exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- src/robot/utils/fuzzy.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py index 9d62023f9e8..c20d148b200 100644 --- a/src/robot/utils/fuzzy.py +++ b/src/robot/utils/fuzzy.py @@ -42,24 +42,24 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No except: logger.warn(f"percent_match parameter invalid: {percent_match}:{type(percent_match)}") percent_match=None - - print("percent_match: ", percent_match, ": ", type(percent_match)) - print("max_errors: ", max_errors, ": ", type(max_errors)) - print("max_insertions: ", max_insertions, ": ", type(max_insertions)) - print("max_deletions: ", max_deletions, ": ", type(max_deletions)) - print("ignore_case: ", ignore_case, ": ", type(ignore_case)) try: if ignore_case: - matches = fuzzysearch.find_near_matches(expected.lower(), buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) + matches = fuzzysearch.find_near_matches(subsequence=expected.lower(), sequence=buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=0) # change matched to contain original, possibly uppercase, input for match in matches: match.matched = buffer[match.start:match.end] else: - matches = fuzzysearch.find_near_matches(expected, buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions) + matches = fuzzysearch.find_near_matches(subsequence=expected, sequence=buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=0) return matches except Exception as e: logger.error(e) + logger.error("percent_match: ", percent_match, ": ", type(percent_match)) + logger.error("max_errors: ", max_errors, ": ", type(max_errors)) + logger.error("max_insertions: ", max_insertions, ": ", type(max_insertions)) + logger.error("max_deletions: ", max_deletions, ": ", type(max_deletions)) + logger.error("ignore_case: ", ignore_case, ": ", type(ignore_case)) + logger.error("\n\n\nexpected:") + logger.error(expected) logger.error("\n\n\nbuffer:") logger.error(buffer) - logger.error("\n\n\nexpected:") From cb9dac0b7b262f615d24971dcce617769d7a01f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Thu, 28 Aug 2025 13:38:45 +0200 Subject: [PATCH 24/25] atest/requirements.txt: Add fuzzysearch library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Gołaś --- atest/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/atest/requirements.txt b/atest/requirements.txt index 5b3ad92adb9..1a510e04246 100644 --- a/atest/requirements.txt +++ b/atest/requirements.txt @@ -6,5 +6,6 @@ pyyaml lxml pillow >= 7.1.0; platform_system == 'Windows' telnetlib-313-and-up; python_version >= '3.13' +fuzzysearch==0.8.0 -r ../utest/requirements.txt From 4725e9c5ef498600490719de9e0197d3b8849463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Go=C5=82a=C5=9B?= Date: Fri, 24 Oct 2025 16:16:43 +0200 Subject: [PATCH 25/25] fuzzysearch: change max_errors to max_substitutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setting max_errors, max_insertions and max_deletions, but not max_substitutions allowed for an edge case to trigger where the fuzzysearch library was crashing. Changing the interface like that should prevent unhandled situations Signed-off-by: Filip Gołaś --- src/robot/libraries/BuiltIn.py | 88 +++++++++++++++--------------- src/robot/libraries/Collections.py | 16 +++--- src/robot/libraries/String.py | 14 ++--- src/robot/libraries/Telnet.py | 38 ++++++------- src/robot/utils/asserts.py | 12 ++-- src/robot/utils/fuzzy.py | 37 +++++-------- 6 files changed, 96 insertions(+), 109 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 02819b398b8..81e66c1ce3e 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -105,9 +105,9 @@ def _matches(self, string, pattern, caseless=False): # Must use this instead of fnmatch when string may contain newlines. matcher = Matcher(pattern, caseless=caseless, spaceless=False) return matcher.match(string) - - def _matches_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, caseless=False): - matches = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors, max_insertions, max_deletions) + + def _matches_fuzzy(self, string, pattern, max_substitutions=None, max_insertions=None, max_deletions=None, caseless=False): + matches = fuzzy.fuzzy_find(string, pattern, max_substitutions, max_insertions, max_deletions) return matches is not None def _is_true(self, condition): @@ -709,10 +709,10 @@ def _should_be_equal(self, first, second, msg, values, formatter="str"): if include_values and isinstance(first, str) and isinstance(second, str): self._raise_multi_diff(first, second, msg, formatter) assert_equal(first, second, msg, include_values, formatter) - + def should_be_equal_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, formatter='str', strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) if isinstance(first, str) and isinstance(second, str): if ignore_case: @@ -724,16 +724,16 @@ def should_be_equal_fuzzy(self, first, second, msg=None, values=True, if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors, max_insertions, max_deletions) + self._should_be_equal_fuzzy(first, second, msg, values, formatter, max_substitutions, max_insertions, max_deletions) - def _should_be_equal_fuzzy(self, first, second, msg, values, formatter='str', percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + def _should_be_equal_fuzzy(self, first, second, msg, values, formatter='str', max_substitutions=None, max_insertions=None, max_deletions=None): include_values = self._include_values(values) formatter = self._get_formatter(formatter) if first == second: return if include_values and isinstance(first, str) and isinstance(second, safe_str): self._raise_multi_diff(first, second, msg, formatter) - assert_equal_fuzzy(first, second, msg, include_values, formatter, percent_match, max_errors, max_insertions, max_deletions) + assert_equal_fuzzy(first, second, msg, include_values, formatter, max_substitutions, max_insertions, max_deletions) def _log_types_at_info_if_different(self, first, second): level = "DEBUG" if type(first) is type(second) else "INFO" @@ -823,7 +823,7 @@ def should_not_be_equal( def should_not_be_equal_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, match_percent=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) if isinstance(first, str) and isinstance(second, str): if ignore_case: @@ -835,13 +835,13 @@ def should_not_be_equal_fuzzy(self, first, second, msg=None, values=True, if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_not_be_equal_fuzzy(first, second, msg, values, match_percent, max_errors, max_insertions, max_deletions) + self._should_not_be_equal_fuzzy(first, second, msg, values, max_substitutions, max_insertions, max_deletions) def _should_not_be_equal(self, first, second, msg, values): assert_not_equal(first, second, msg, self._include_values(values)) - def _should_not_be_equal_fuzzy(self, first, second, msg, values, match_percent, max_errors, max_insertions=None, max_deletions=None): - assert_not_equal_fuzzy(first, second, msg, self._include_values(values), match_percent, max_errors, max_insertions, max_deletions) + def _should_not_be_equal_fuzzy(self, first, second, msg, values, max_substitutions, max_insertions=None, max_deletions=None): + assert_not_equal_fuzzy(first, second, msg, self._include_values(values), max_substitutions, max_insertions, max_deletions) def should_not_be_equal_as_integers( self, @@ -1015,7 +1015,7 @@ def should_not_be_equal_as_strings( def should_not_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) first = safe_str(first) second = safe_str(second) @@ -1028,7 +1028,7 @@ def should_not_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=T if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_not_be_equal_fuzzy(first, second, msg, values, percent_match, max_errors, max_insertions, max_deletions) + self._should_not_be_equal_fuzzy(first, second, msg, values, max_substitutions, max_insertions, max_deletions) def should_be_equal_as_strings( self, @@ -1078,10 +1078,10 @@ def should_be_equal_as_strings( first = self._collapse_spaces(first) second = self._collapse_spaces(second) self._should_be_equal(first, second, msg, values, formatter) - + def should_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=True, ignore_case=False, strip_spaces=False, - formatter='str', collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + formatter='str', collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): self._log_types_at_info_if_different(first, second) first = safe_str(first) second = safe_str(second) @@ -1094,7 +1094,7 @@ def should_be_equal_as_strings_fuzzy(self, first, second, msg=None, values=True, if collapse_spaces: first = self._collapse_spaces(first) second = self._collapse_spaces(second) - self._should_be_equal_fuzzy(first, second, msg, values, formatter, percent_match, max_errors, max_insertions, max_deletions) + self._should_be_equal_fuzzy(first, second, msg, values, formatter, max_substitutions, max_insertions, max_deletions) def should_not_start_with( self, @@ -1128,7 +1128,7 @@ def should_not_start_with( def should_not_start_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1138,7 +1138,7 @@ def should_not_start_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(str1, str2, max_substitutions, max_insertions, max_deletions) if matched is not None: if matched.start < 1: raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1177,7 +1177,7 @@ def should_start_with( def should_start_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1187,7 +1187,7 @@ def should_start_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(str1, str2, max_substitutions, max_insertions, max_deletions) if matched is None: if matched.start < 1: raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1226,7 +1226,7 @@ def should_not_end_with( def should_not_end_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1236,7 +1236,7 @@ def should_not_end_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(str1, str2, max_substitutions, max_insertions, max_deletions) if matched is None: if matched.end == len(str1): raise AssertionError(self._get_string_msg(str1, str2, msg, values, @@ -1275,7 +1275,7 @@ def should_end_with( def should_end_with_fuzzy(self, str1, str2, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): if ignore_case: str1 = str1.lower() str2 = str2.lower() @@ -1285,12 +1285,12 @@ def should_end_with_fuzzy(self, str1, str2, msg=None, values=True, if collapse_spaces: str1 = self._collapse_spaces(str1) str2 = self._collapse_spaces(str2) - matched = fuzzy.fuzzy_find(str1, str2, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(str1, str2, max_substitutions, max_insertions, max_deletions) if matched is None: if matched.end != len(str1): raise AssertionError(self._get_string_msg(str1, str2, msg, values, 'does not end with')) - + def should_not_contain( self, container, @@ -1360,7 +1360,7 @@ def should_not_contain( def should_not_contain_fuzzy(self, container, item, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): orig_container = container if ignore_case and isinstance(item, str): item = item.lower() @@ -1380,7 +1380,7 @@ def should_not_contain_fuzzy(self, container, item, msg=None, values=True, container = self._collapse_spaces(container) elif is_list_like(container): container = set(self._collapse_spaces(x) for x in container) - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(container, item, max_substitutions, max_insertions, max_deletions) if matched is not None: raise AssertionError(self._get_string_msg(orig_container, item, msg, values, 'contains')) @@ -1473,7 +1473,7 @@ def should_contain( def should_contain_fuzzy(self, container, item, msg=None, values=True, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): orig_container = container if ignore_case and isinstance(item, str): item = item.lower() @@ -1493,7 +1493,7 @@ def should_contain_fuzzy(self, container, item, msg=None, values=True, container = self._collapse_spaces(container) elif is_list_like(container): container = set(self._collapse_spaces(x) for x in container) - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(container, item, max_substitutions, max_insertions, max_deletions) if matched is None: raise AssertionError(self._get_string_msg(orig_container, item, msg, values, 'does not contain')) @@ -1565,8 +1565,7 @@ def should_contain_any_fuzzy(self, container, *items, **configuration): values = configuration.pop('values', True) ignore_case = is_truthy(configuration.pop('ignore_case', False)) strip_spaces = configuration.pop('strip_spaces', False) - percent_match = configuration.pop('percent_match', None) - max_errors = configuration.pop('max_errors', None) + max_substitutions = configuration.pop('max_substitutions', None) max_insertions = configuration.pop('max_insertions', None) max_deletions = configuration.pop('max_deletions', None) collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) @@ -1596,10 +1595,10 @@ def should_contain_any_fuzzy(self, container, *items, **configuration): container = set(self._collapse_spaces(x) for x in container) for item in items: - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(container, item, max_substitutions, max_insertions, max_deletions) if matched is not None: return - + msg = self._get_string_msg(orig_container, seq2str(items, lastsep=' or '), msg, values, @@ -1674,8 +1673,7 @@ def should_not_contain_any_fuzzy(self, container, *items, **configuration): values = configuration.pop('values', True) ignore_case = is_truthy(configuration.pop('ignore_case', False)) strip_spaces = configuration.pop('strip_spaces', False) - percent_match = configuration.pop('percent_match', None) - max_errors = configuration.pop('max_errors', None) + max_substitutions = configuration.pop('max_substitutions', None) max_insertions = configuration.pop('max_insertions', None) max_deletions = configuration.pop('max_deletions', None) collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) @@ -1705,7 +1703,7 @@ def should_not_contain_any_fuzzy(self, container, *items, **configuration): container = set(self._collapse_spaces(x) for x in container) for item in items: - matched = fuzzy.fuzzy_find(container, item, percent_match, max_errors, max_insertions, max_deletions) + matched = fuzzy.fuzzy_find(container, item, max_substitutions, max_insertions, max_deletions) if matched is not None: msg = self._get_string_msg(orig_container, seq2str(items, lastsep=' or '), @@ -1785,7 +1783,7 @@ def should_contain_x_times( def should_contain_x_times_fuzzy(self, container, item, count, msg=None, ignore_case=False, strip_spaces=False, - collapse_spaces=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + collapse_spaces=False, max_substitutions=None, max_insertions=None, max_deletions=None): count = self._convert_to_integer(count) orig_container = container if isinstance(item, str): @@ -1807,7 +1805,7 @@ def should_contain_x_times_fuzzy(self, container, item, count, msg=None, container = self._collapse_spaces(container) elif is_list_like(container): container = [self._collapse_spaces(x) for x in container] - matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors, max_insertions, max_deletions) + matches = fuzzy.fuzzy_find_all(container, item, max_substitutions, max_insertions, max_deletions) x = len(matches) if not msg: msg = "%r contains '%s' %d time%s, not %d time%s." \ @@ -1835,14 +1833,14 @@ def get_count(self, container, item): self.log(f"Item found from container {count} time{s(count)}.") return count - def get_count_fuzzy(self, container, item, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + def get_count_fuzzy(self, container, item, max_substitutions=None, max_insertions=None, max_deletions=None): if not hasattr(container, 'count'): try: container = list(container) except: raise RuntimeError("Converting '%s' to list failed: %s" % (container, get_error_message())) - matches = fuzzy.fuzzy_find_all(container, item, percent_match, max_errors, max_insertions, max_deletions) + matches = fuzzy.fuzzy_find_all(container, item, max_substitutions, max_insertions, max_deletions) count = len(matches) self.log('Item found from container %d time%s.' % (count, s(count))) return count @@ -1874,8 +1872,8 @@ def should_not_match( ) def should_not_match_fuzzy(self, string, pattern, msg=None, values=True, - ignore_case=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): - matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors, max_insertions, max_deletions) + ignore_case=False, max_substitutions=None, max_insertions=None, max_deletions=None): + matched = fuzzy.fuzzy_find(string, pattern, max_substitutions, max_insertions, max_deletions) if matched is not None: raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'matches')) @@ -1900,8 +1898,8 @@ def should_match(self, string, pattern, msg=None, values=True, ignore_case=False ) def should_match_fuzzy(self, string, pattern, msg=None, values=True, - ignore_case=False, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): - matched = fuzzy.fuzzy_find(string, pattern, percent_match, max_errors, max_insertions, max_deletions) + ignore_case=False, max_substitutions=None, max_insertions=None, max_deletions=None): + matched = fuzzy.fuzzy_find(string, pattern, max_substitutions, max_insertions, max_deletions) if matched is None: raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'matches')) diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 02a1702b54b..d517d270f69 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -263,8 +263,8 @@ def get_index_from_list(self, list_, value, start=0, end=None): return start + list_.index(value) except ValueError: return -1 - - def get_index_from_list_fuzzy(self, list_, value, start=0, end=None, percent_match=None, max_errors=None): + + def get_index_from_list_fuzzy(self, list_, value, start=0, end=None, max_substitutions=None, max_insertions=None, max_deletions=None): """Returns the index of the first occurrence of the ``value`` on the list. The search can be narrowed to the selected sublist by the ``start`` and @@ -283,7 +283,7 @@ def get_index_from_list_fuzzy(self, list_, value, start=0, end=None, percent_mat list_ = self.get_slice_from_list(list_, start, end) try: for idx, item in enumerate(list_): - if fuzzy.fuzzy_find(item, value, percent_match, max_errors): + if fuzzy.fuzzy_find(item, value, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions): return start + idx except ValueError: return -1 @@ -345,14 +345,14 @@ def list_should_contain_value(self, list_, value, msg=None, ignore_case=False): msg, ) - def list_should_contain_value_fuzzy(self, list_, value, msg=None, ignore_case=False, percent_match=None, max_errors=None): + def list_should_contain_value_fuzzy(self, list_, value, msg=None, ignore_case=False, max_insertions=None, max_deletions=None, max_substitutions=None): self._validate_list(list_) normalize = Normalizer(ignore_case).normalize v = normalize(value) l = normalize(list_) found = False for item in l: - if fuzzy.fuzzy_find(item, v, percent_match, max_errors) is not None: + if fuzzy.fuzzy_find(item, v, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) is not None: found=True break _verify_condition( @@ -377,15 +377,15 @@ def list_should_not_contain_value(self, list_, value, msg=None, ignore_case=Fals f"{seq2str2(list_)} contains value '{value}'.", msg, ) - - def list_should_not_contain_value_fuzzy(self, list_, value, msg=None, ignore_case=False, percent_match=None, max_errors=None): + + def list_should_not_contain_value_fuzzy(self, list_, value, msg=None, ignore_case=False, max_insertions=None, max_deletions=None, max_substitutions=None): self._validate_list(list_) normalize = Normalizer(ignore_case).normalize v = normalize(value) l = normalize(list_) found = False for item in l: - if fuzzy.fuzzy_find(item, v, percent_match, max_errors): + if fuzzy.fuzzy_find(item, v, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions): found=True break _verify_condition( diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index a91b6c10a0d..5d274eb8cc0 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -279,7 +279,7 @@ def get_line_number_containing_string(self, string, pattern, case_insensitive=Fa Examples: | ${lines} = | Get Line Number Containing String | ${result} | An example | | ${ret} = | Get Line Number Containing String | ${ret} | FAIL | case-insensitive | - + If multiple line match only line number of first occurrence is returned. """ for n,l in enumerate(string.splitlines()): @@ -288,8 +288,8 @@ def get_line_number_containing_string(self, string, pattern, case_insensitive=Fa else: ret = 0 return ret - - def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, case_insensitive=False): + + def get_line_number_containing_string_fuzzy(self, string, pattern, max_substitutions=None, max_insertions=None, max_deletions=None, case_insensitive=False): """Returns line number of the given ``string`` that contain the ``pattern``.` The ``pattern`` is always considered to be a normal string, not a glob or regexp pattern. A line matches if the ``pattern`` is found anywhere @@ -298,11 +298,11 @@ def get_line_number_containing_string_fuzzy(self, string, pattern, percent_match Examples: | ${lines} = | Get Line Number Containing String | ${result} | An example | | ${ret} = | Get Line Number Containing String | ${ret} | FAIL | case-insensitive | - + If multiple line match only line number of first occurrence is returned. """ for n,l in enumerate(string.splitlines()): - matches = fuzzy.fuzzy_find(l, pattern, percent_match, max_errors, max_insertions, max_deletions, ignore_case=case_insensitive) + matches = fuzzy.fuzzy_find(l, pattern, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions, ignore_case=case_insensitive) if len(matches) > 0: return n return 0 @@ -388,8 +388,8 @@ def get_lines_matching_pattern( matches = lambda line: fnmatchcase(line, pattern) return self._get_matching_lines(string, matches) - def get_lines_matching_fuzzy(self, string, pattern, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, case_insensitive=False): - matches = lambda line: len(fuzzy.fuzzy_find(line.lower(), pattern.lower(), percent_match, max_errors, max_insertions, max_deletions, ignore_case=case_insensitive)) > 0 + def get_lines_matching_fuzzy(self, string, pattern, max_substitutions=None, max_insertions=None, max_deletions=None, case_insensitive=False): + matches = lambda line: len(fuzzy.fuzzy_find(line.lower(), pattern.lower(), max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions, ignore_case=case_insensitive)) > 0 return self._get_matching_lines(string, matches) def get_lines_matching_regexp( diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 9e8d7d917d9..6e829d8d6d6 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -858,14 +858,14 @@ def _get_newline_for(self, text): def write_bare(self, text, char_delay=None): """Writes the given text, and nothing else, into the connection. - + If char_delay parameter specified function sends characters one by one with delay defined in seconds. - + This keyword does not append a newline nor consume the written text. Use `Write` if these features are needed. """ - + self._verify_connection() if char_delay: for ch in list(text): @@ -985,24 +985,24 @@ def read_until(self, expected, loglevel=None): if not success: raise NoMatchError(expected, self._timeout, output) return output - + @keyword - def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None, loglevel=None): - success, output = self._read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) + def read_until_fuzzy(self, expected, max_substitutions=None, max_insertions=None, max_deletions=None, loglevel=None): + success, output = self._read_until_fuzzy(expected, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) self._log(output, loglevel) if not success: raise NoMatchError(expected, self._timeout, output) return output - def _read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + def _read_until_fuzzy(self, expected, max_substitutions=None, max_insertions=None, max_deletions=None): self._verify_connection() if self._terminal_emulator: - return self._terminal_read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) + return self._terminal_read_until_fuzzy(expected, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) expected = self._encode(expected) - output = telnetlib.Telnet.read_until_fuzzy(self, expected, self._timeout, percent_match, max_errors, max_insertions, max_deletions) - found = fuzzy.fuzzy_find(output, expected, percent_match, max_errors, max_insertions, max_deletions) is not None + output = telnetlib.Telnet.read_until_fuzzy(self, expected, self._timeout, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) + found = fuzzy.fuzzy_find(output, expected, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) is not None return found, self._decode(output) - + def _read_until(self, expected): self._verify_connection() if self._terminal_emulator: @@ -1029,17 +1029,17 @@ def _terminal_read_until(self, expected): if output: return True, output return False, self._terminal_emulator.read() - - def _terminal_read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + + def _terminal_read_until_fuzzy(self, expected, max_substitutions=None, max_insertions=None, max_deletions=None): max_time = time.time() + self._timeout - output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) + output = self._terminal_emulator.read_until_fuzzy(expected, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) if output: return True, output while time.time() < max_time: output = telnetlib.Telnet.read_until_fuzzy(self, self._encode(expected), - self._terminal_frequency, percent_match, max_errors, max_insertions, max_deletions) + self._terminal_frequency, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) self._terminal_emulator.feed(self._decode(output)) - output = self._terminal_emulator.read_until_fuzzy(expected, percent_match, max_errors, max_insertions, max_deletions) + output = self._terminal_emulator.read_until_fuzzy(expected, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) if output: return True, output return False, self._terminal_emulator.read() @@ -1345,13 +1345,13 @@ def read_until(self, expected): return current_out[: exp_index + len(expected)] return None - def read_until_fuzzy(self, expected, percent_match=None, max_errors=None, max_insertions=None, max_deletions=None): + def read_until_fuzzy(self, expected, max_substitutions=None, max_insertions=None, max_deletions=None): current_out = self.current_output - match = fuzzy.fuzzy_find(current_out, expected, percent_match, max_errors, max_insertions, max_deletions) + match = fuzzy.fuzzy_find(current_out, expected, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) if match is None: return None - + exp_index = match.start match_len = len(match.matched) current_out.find(expected) diff --git a/src/robot/utils/asserts.py b/src/robot/utils/asserts.py index 965bd2d411d..133219bddcf 100644 --- a/src/robot/utils/asserts.py +++ b/src/robot/utils/asserts.py @@ -180,15 +180,15 @@ def assert_equal(first, second, msg=None, values=True, formatter=safe_str): if not first == second: # noqa: SIM201 _report_inequality(first, second, "!=", msg, values, formatter) -def assert_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None, max_insertions=None, max_deletions=None): - """Fail if given objects are unequal as determined by fuzzy comparison. Default is 90% similarity.""" - match = fuzzy._fuzzy_find(first, second, percent_match, max_errors, max_insertions, max_deletions) +def assert_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, max_substitutions=None, max_insertions=None, max_deletions=None): + """Fail if given objects are unequal as determined by fuzzy comparison.""" + match = fuzzy._fuzzy_find(first, second, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) if not len(match) > 0: _report_inequality(first, second, '!=', msg, values, formatter) -def assert_not_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, percent_match=90, max_errors=None, max_insertions=None, max_deletions=None): - """Fail if given objects are equal as determined by fuzzy comparison. Default is 90% similarity.""" - match = fuzzy._fuzzy_find(first, second, percent_match, max_errors, max_insertions, max_deletions) +def assert_not_equal_fuzzy(first, second, msg=None, values=True, formatter=safe_str, max_substitutions=None, max_insertions=None, max_deletions=None): + """Fail if given objects are equal as determined by fuzzy comparison.""" + match = fuzzy._fuzzy_find(first, second, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) if len(match) > 0: _report_inequality(first, second, '!=', msg, values, formatter) diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py index c20d148b200..2a292e923d5 100644 --- a/src/robot/utils/fuzzy.py +++ b/src/robot/utils/fuzzy.py @@ -2,16 +2,14 @@ import fuzzysearch from robot.api import logger -def fuzzy_find(buffer, expected, percent_match=None, max_errors=None, max_insertions:int=None, max_deletions:int=None, ignore_case=False): - found = fuzzy_find_all(buffer, expected, percent_match, max_errors, max_insertions, max_deletions, ignore_case) +def fuzzy_find(buffer, expected, max_substitutions:int=None, max_insertions:int=None, max_deletions:int=None, ignore_case=False): + found = fuzzy_find_all(buffer, expected, max_substitutions, max_insertions, max_deletions, ignore_case) if len(found) > 0: return found[0] return None -def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=None, max_insertions:int=None, max_deletions:int=None, ignore_case:bool=False): - max_l_dist = 0 - +def fuzzy_find_all(buffer, expected, max_substitutions:int=None, max_insertions:int=None, max_deletions:int=None, ignore_case:bool=False): if max_insertions is not None: try: max_insertions=int(max_insertions) @@ -26,35 +24,26 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No logger.warn(f"max_deletions parameter invalid: {max_deletions}:{type(max_deletions)}") max_deletions=None - if max_errors is not None: - try: - max_errors = int(max_errors) - max_l_dist = max_errors - except: - logger.warn(f"max_errors parameter invalid: {max_errors}:{type(max_errors)}") - max_errors=None - elif percent_match is not None: + if max_substitutions is not None: try: - percent_match = float(percent_match) - percent_match = min(100, max(0, percent_match)) # limit to 0-100 - percent_errors = 100 - percent_match - max_l_dist = int(round(len(expected) * percent_errors / 100.0)) + max_substitutions=int(max_substitutions) except: - logger.warn(f"percent_match parameter invalid: {percent_match}:{type(percent_match)}") - percent_match=None + logger.warn(f"max_substitutions parameter invalid: {max_substitutions}:{type(max_substitutions)}") + max_substitutions=None + try: if ignore_case: - matches = fuzzysearch.find_near_matches(subsequence=expected.lower(), sequence=buffer.lower(), max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=0) + matches = fuzzysearch.find_near_matches(subsequence=expected.lower(), sequence=buffer.lower(), max_l_dist=None, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) # change matched to contain original, possibly uppercase, input for match in matches: match.matched = buffer[match.start:match.end] else: - matches = fuzzysearch.find_near_matches(subsequence=expected, sequence=buffer, max_l_dist=max_l_dist, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=0) + matches = fuzzysearch.find_near_matches(subsequence=expected, sequence=buffer, max_l_dist=None, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) return matches + except Exception as e: logger.error(e) - logger.error("percent_match: ", percent_match, ": ", type(percent_match)) - logger.error("max_errors: ", max_errors, ": ", type(max_errors)) + logger.error("max_substitutions: ", max_substitutions, ": ", type(max_substitutions)) logger.error("max_insertions: ", max_insertions, ": ", type(max_insertions)) logger.error("max_deletions: ", max_deletions, ": ", type(max_deletions)) logger.error("ignore_case: ", ignore_case, ": ", type(ignore_case)) @@ -62,4 +51,4 @@ def fuzzy_find_all(buffer, expected, percent_match:float=None, max_errors:int=No logger.error(expected) logger.error("\n\n\nbuffer:") logger.error(buffer) - +