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 diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index bdbf6918bac..81e66c1ce3e 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 @@ -105,6 +106,10 @@ def _matches(self, string, pattern, caseless=False): matcher = Matcher(pattern, caseless=caseless, spaceless=False) return matcher.match(string) + 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): if isinstance(condition, str): condition = self.evaluate(condition) @@ -705,6 +710,31 @@ def _should_be_equal(self, first, second, msg, values, formatter="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, 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: + 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, max_substitutions, max_insertions, max_deletions) + + 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, 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" self._log_types_at_level(level, first, second) @@ -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, 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: + 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, 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, 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, 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, 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) + 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, max_substitutions, max_insertions, max_deletions) + def should_be_equal_as_strings( self, first, @@ -1013,6 +1079,23 @@ 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_fuzzy(self, first, second, msg=None, values=True, + ignore_case=False, strip_spaces=False, + 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) + 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, max_substitutions, max_insertions, max_deletions) + def should_not_start_with( self, str1, @@ -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, max_substitutions=None, max_insertions=None, max_deletions=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, 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, + '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, max_substitutions=None, max_insertions=None, max_deletions=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, max_substitutions, max_insertions, max_deletions) + 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, max_substitutions=None, max_insertions=None, max_deletions=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, 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, + '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, max_substitutions=None, max_insertions=None, max_deletions=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, 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, @@ -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, max_substitutions=None, max_insertions=None, max_deletions=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, max_substitutions, max_insertions, max_deletions) + 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, max_substitutions=None, max_insertions=None, max_deletions=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, max_substitutions, max_insertions, max_deletions) + 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,52 @@ 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) + 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)) + 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, 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, + 'does not contain any of', + quote_item2=False) + raise AssertionError(msg) + def should_not_contain_any( self, container, @@ -1410,6 +1668,50 @@ 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) + 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)) + 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, max_substitutions, max_insertions, max_deletions) + 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 +1781,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, max_substitutions=None, max_insertions=None, max_deletions=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, max_substitutions, max_insertions, max_deletions) + 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 +1833,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, 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, max_substitutions, max_insertions, max_deletions) + 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 +1871,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, 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')) + 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 +1897,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, 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')) + def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None): """Fails if ``string`` does not match ``pattern`` as a regular expression. diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 8711ec63bc9..d517d270f69 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 @@ -264,6 +264,30 @@ def get_index_from_list(self, list_, value, start=0, end=None): except ValueError: return -1 + 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 + ``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, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions): + return start + idx + except ValueError: + return -1 + def copy_list(self, list_, deepcopy=False): """Returns a copy of the given list. @@ -321,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, 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, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) 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``. @@ -338,6 +378,22 @@ def list_should_not_contain_value(self, list_, value, msg=None, ignore_case=Fals msg, ) + 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, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions): + 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. diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index e6483f906da..5d274eb8cc0 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 @@ -278,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,6 +289,24 @@ 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, 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 + 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, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions, ignore_case=case_insensitive) + if len(matches) > 0: + return n + return 0 + def get_lines_containing_string( self, string: str, @@ -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, 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( self, string, diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 5c6aa9224a1..6e829d8d6d6 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: @@ -344,9 +345,10 @@ def _get_library_keywords(self): return self._lib_kws def _get_keywords(self, source, excluded): - return [ + kwds = [ name for name in dir(source) if self._is_keyword(name, source, excluded) ] + return kwds def _is_keyword(self, name, source, excluded): return ( @@ -362,7 +364,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 @@ -856,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): @@ -984,6 +986,23 @@ def read_until(self, expected, loglevel=None): raise NoMatchError(expected, self._timeout, output) return output + @keyword + 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, max_substitutions=None, max_insertions=None, max_deletions=None): + self._verify_connection() + if self._terminal_emulator: + 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, 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: @@ -1011,6 +1030,20 @@ def _terminal_read_until(self, expected): return True, output return False, self._terminal_emulator.read() + 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, 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, 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, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) + if output: + return True, output + return False, self._terminal_emulator.read() + def _read_until_regexp(self, *expected): self._verify_connection() if self._terminal_emulator: @@ -1312,6 +1345,21 @@ def read_until(self, expected): return current_out[: exp_index + len(expected)] return 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, 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) + 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 +1394,5 @@ def _get_message(self): if self.output is not None: msg += " Output:\n" + self.output return msg + + diff --git a/src/robot/utils/asserts.py b/src/robot/utils/asserts.py index 939e5416626..133219bddcf 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, 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, 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) + + def assert_not_equal(first, second, msg=None, values=True, formatter=safe_str): """Fail if given objects are equal as determined by the '==' operator.""" diff --git a/src/robot/utils/fuzzy.py b/src/robot/utils/fuzzy.py new file mode 100644 index 00000000000..2a292e923d5 --- /dev/null +++ b/src/robot/utils/fuzzy.py @@ -0,0 +1,54 @@ + +import fuzzysearch +from robot.api import logger + +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, 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) + 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_substitutions is not None: + try: + max_substitutions=int(max_substitutions) + except: + 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=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=None, max_insertions=max_insertions, max_deletions=max_deletions, max_substitutions=max_substitutions) + return matches + + except Exception as e: + logger.error(e) + 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)) + logger.error("\n\n\nexpected:") + logger.error(expected) + logger.error("\n\n\nbuffer:") + logger.error(buffer) + 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