Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
162fbf3
utils/fuzzy.py: Add
philipanda Aug 1, 2025
5ade153
libraries/Telnet.py: Add fuzzy string comparisons
philipanda Aug 1, 2025
4565ec3
libraries/String.py: Add fuzzy string comparisons
philipanda Aug 1, 2025
bf1ac06
utils/asserts.py: Add fuzzy comparisons
philipanda Aug 1, 2025
c163bc5
libraries/Builtin.py: Add fuzzy comparisons
philipanda Aug 1, 2025
62394c7
utest/requirements.txt: Add fuzzysearch requirement
philipanda Aug 1, 2025
eadddbe
Collections.py: Add get_index_from_list_fuzzy
philipanda Aug 5, 2025
84a2475
Telnet.py: Export Read Until Fuzzy keyword
philipanda Aug 5, 2025
904a0c5
Telnet.py: Fix missing args to terminal_reauntil_fuzzy
philipanda Aug 5, 2025
549fe94
Collections.py: Add List Should Contain Fuzzy
philipanda Aug 5, 2025
9c42c5d
Telnet.py: Add keyword decorator to new keyword
philipanda Aug 6, 2025
f03193c
Telnet.py: Add _get_keywords debug prints
philipanda Aug 6, 2025
5da534d
utils/fuzzy.py: Add max_insertions and max_deletions
philipanda Aug 8, 2025
ff6b663
libraries/Telnet.py: Add max_insertions and max_deletions to fuzzy me…
philipanda Aug 8, 2025
7132b10
libraries/Strings.py: Add max_insertions and max_deletions to fuzzy m…
philipanda Aug 8, 2025
152f562
util/asserts.py: Add max_insertions and max_deletions to fuzzy methods
philipanda Aug 8, 2025
6a5406d
BuiltIn.py: Add max_insertions and max_deletions to fuzzy methods
philipanda Aug 8, 2025
d18d738
String.py: Add max_insertions and max_deletions to fuzzy methods
philipanda Aug 8, 2025
a17d723
utils/fuzzy.py: Add exception logging
philipanda Aug 8, 2025
1ff1ce2
Telnet.py: Remove debug prints
philipanda Aug 8, 2025
c1d1993
fuzzy.py: Add arguments debug prints
philipanda Aug 20, 2025
16264f7
fuzzy.py: Add robust type checks
philipanda Aug 20, 2025
4da9351
fuzzy.py: print logs only on exceptions
philipanda Aug 20, 2025
cb9dac0
atest/requirements.txt: Add fuzzysearch library
philipanda Aug 28, 2025
4725e9c
fuzzysearch: change max_errors to max_substitutions
philipanda Oct 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
360 changes: 359 additions & 1 deletion src/robot/libraries/BuiltIn.py

Large diffs are not rendered by default.

58 changes: 57 additions & 1 deletion src/robot/libraries/Collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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, 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``.

Expand All @@ -337,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.
Expand Down
23 changes: 23 additions & 0 deletions src/robot/libraries/String.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 62 additions & 1 deletion src/robot/libraries/Telnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import time
from contextlib import contextmanager

import robot.utils.fuzzy as fuzzy
try:
import pyte
except ImportError:
Expand Down Expand Up @@ -362,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
Expand Down Expand Up @@ -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, 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
return found, self._decode(output)

def _read_until(self, expected):
self._verify_connection()
if self._terminal_emulator:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1346,3 +1405,5 @@ def _get_message(self):
if self.output is not None:
msg += " Output:\n" + self.output
return msg


14 changes: 14 additions & 0 deletions src/robot/utils/asserts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
32 changes: 32 additions & 0 deletions src/robot/utils/fuzzy.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions utest/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
docutils >= 0.10
jsonschema
typing_extensions >= 4.13
fuzzysearch==0.8.0
Loading