From cf678eefd7fb317e3d328138411c8909102d75ff Mon Sep 17 00:00:00 2001 From: DarkRedman Date: Wed, 25 Mar 2026 06:36:58 +0100 Subject: [PATCH 1/5] test(tests): migrate to pytest and move tests to tests/ directory --- test_similar.py | 49 ------------------------ test_wordlist.txt | 8 ---- tests/test_similar.py | 88 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 57 deletions(-) delete mode 100644 test_similar.py delete mode 100644 test_wordlist.txt create mode 100644 tests/test_similar.py diff --git a/test_similar.py b/test_similar.py deleted file mode 100644 index ac80616..0000000 --- a/test_similar.py +++ /dev/null @@ -1,49 +0,0 @@ -#-*- coding: utf-8 -* - -import unittest -from similar.similar import Similar -from similar.similar import best_match -from similar.exceptions import NoResultException - - -class SimilarTestCase(unittest.TestCase): - - def setUp(self): - with open('test_wordlist.txt') as f: - self.haystack = [word.strip() for word in f] - self.needle = 'bananna' - self.correct_word = 'banana' - - def test_similar_in_list(self): - s = Similar(self.needle, self.haystack) - self.assertEqual(s.best(), self.correct_word) - - def test_similar_in_wordlist(self): - s = Similar(self.needle, open('test_wordlist.txt')) - self.assertEqual(s.best(), self.correct_word) - - def test_similar_in_generator(self): - def genwords(): - with open('test_wordlist.txt') as f: - for line in f: - yield line - - s = Similar(self.needle, genwords()) - self.assertEqual(s.best(), self.correct_word) - - def test_best_match(self): - best = best_match(self.needle, self.haystack) - self.assertEqual(best, self.correct_word) - - def test_results(self): - s = Similar(self.needle, self.haystack) - self.assertEqual(len(s.results()), len(self.haystack)) - - def test_no_result_exception(self): - with self.assertRaises(NoResultException): - s = Similar('foo', []) - s.best() - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/test_wordlist.txt b/test_wordlist.txt deleted file mode 100644 index 6a34d03..0000000 --- a/test_wordlist.txt +++ /dev/null @@ -1,8 +0,0 @@ -apple -tomato -banana -orange -apricot -raspberry -kiwi -pear \ No newline at end of file diff --git a/tests/test_similar.py b/tests/test_similar.py new file mode 100644 index 0000000..b5ad557 --- /dev/null +++ b/tests/test_similar.py @@ -0,0 +1,88 @@ +import pytest +from similar.similar import Similar, best_match, NoResultException + +@pytest.fixture +def sample_words(): + return ["apple", "raspberry", "pear", "banana"] + + +@pytest.fixture +def similar_instance(sample_words): + return Similar("bananna", sample_words) + + +@pytest.mark.parametrize("input_word,expected", [ + ("bananna", "banana"), + ("rasbery", "raspberry"), + ("aple", "apple"), +]) +def test_best_multiple(input_word, expected): + s = Similar(input_word, ["apple", "raspberry", "pear", "banana"]) + assert s.best() == expected + + +def test_results_returns_list_of_tuples(similar_instance): + results = similar_instance.results() + assert isinstance(results, list) + assert all(isinstance(item, tuple) for item in results) + assert all(isinstance(item[0], str) for item in results) + assert all(isinstance(item[1], float) for item in results) + + +def test_results_sorted_descending(similar_instance): + results = similar_instance.results() + scores = [score for _, score in results] + assert scores == sorted(scores, reverse=True) + + +def test_best_matches_first_result(similar_instance): + results = similar_instance.results() + assert similar_instance.best() == results[0][0] + + +def test_no_result_exception_on_empty_list(): + s = Similar("test", []) + with pytest.raises(NoResultException): + s.best() + + +def test_no_result_exception_on_no_match(): + s = Similar("zzz", []) + with pytest.raises(NoResultException): + s.results() + + +def test_results_are_strictly_sorted(): + s = Similar("abc", ["abc", "abd", "zzz"]) + results = s.results() + for i in range(len(results) - 1): + assert results[i][1] >= results[i + 1][1] + + +def test_deterministic_results(): + words = ["apple", "raspberry", "pear", "banana"] + s1 = Similar("bananna", words) + s2 = Similar("bananna", words) + assert s1.results() == s2.results() + + +def test_generator_input(): + def genwords(): + for word in ["apple", "raspberry", "pear", "banana"]: + yield word + s = Similar("bananna", genwords()) + assert s.best() == "banana" + + +def test_file_like_input(tmp_path): + file = tmp_path / "words.txt" + file.write_text("apple\nraspberry\npear\nbanana\n") + with open(file) as f: + s = Similar("bananna", f) + assert s.best() == "banana" + + +def test_best_match_function(): + words = ["apple", "raspberry", "pear", "banana"] + best = best_match("bananna", words) + assert best == "banana" From e0f95de4515227278be6e435ee58199a5eef5203 Mon Sep 17 00:00:00 2001 From: DarkRedman Date: Wed, 25 Mar 2026 06:52:30 +0100 Subject: [PATCH 2/5] style(python): remove obsolete utf-8 coding headers --- similar/__init__.py | 2 -- similar/exceptions.py | 2 -- similar/similar.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/similar/__init__.py b/similar/__init__.py index 12d142d..031d055 100644 --- a/similar/__init__.py +++ b/similar/__init__.py @@ -1,4 +1,2 @@ -# -*- coding: utf-8 -* - from .similar import Similar # noqa from .similar import best_match # noqa diff --git a/similar/exceptions.py b/similar/exceptions.py index 76fb077..8c4a997 100644 --- a/similar/exceptions.py +++ b/similar/exceptions.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ This module contains the list of similar exceptions """ diff --git a/similar/similar.py b/similar/similar.py index b8de02a..add4a71 100644 --- a/similar/similar.py +++ b/similar/similar.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -* - import operator import Levenshtein from .exceptions import NoResultException From 8f9b4cc77094ee30690b3ffbc7e79caf4fdf859e Mon Sep 17 00:00:00 2001 From: DarkRedman Date: Wed, 25 Mar 2026 06:54:12 +0100 Subject: [PATCH 3/5] chore(git): ignore coverage and pytest cache files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4ec4492..9dda728 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ build *.egg-info .idea .tox +.coverage +.pytest_cache \ No newline at end of file From 02ac7491ac1ce473df65e157b5fc01c491548c43 Mon Sep 17 00:00:00 2001 From: DarkRedman Date: Tue, 31 Mar 2026 05:29:34 +0200 Subject: [PATCH 4/5] feat(similar): add optimized search API with RapidFuzz, scoring options and top_k - replace python-Levenshtein with RapidFuzz - add pluggable algorithms with aliases (typo, fuzzy, name, etc.) - add score_cutoff, score_hint, top_k and processor parameters - use rapidfuzz.process.extract for significant performance improvements - keep backward compatibility with existing get() API --- requirements.txt | 2 +- similar/similar.py | 68 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 199031c..34334d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -python-Levenshtein>=0.12.0 +rapidfuzz>=3.5.2 diff --git a/similar/similar.py b/similar/similar.py index add4a71..ae3fefb 100644 --- a/similar/similar.py +++ b/similar/similar.py @@ -1,16 +1,62 @@ import operator -import Levenshtein +from rapidfuzz import distance, process, fuzz from .exceptions import NoResultException +ALGOS = { + "levenshtein": distance.Levenshtein.normalized_similarity, + "damerau": distance.DamerauLevenshtein.normalized_similarity, + "osa": distance.OSA.normalized_similarity, + "jaro": distance.Jaro.normalized_similarity, + "winkler": distance.JaroWinkler.normalized_similarity, + "hamming": distance.Hamming.normalized_similarity, + "indel": distance.Indel.normalized_similarity, + "lcs": distance.LCSseq.normalized_similarity, + "prefix": distance.Prefix.normalized_similarity, + "suffix": distance.Postfix.normalized_similarity, + "qratio": fuzz.QRatio, + "wratio": fuzz.WRatio, + + # aliases + "typo": distance.DamerauLevenshtein.normalized_similarity, + "fast_typo": distance.OSA.normalized_similarity, + "name": distance.JaroWinkler.normalized_similarity, + "fuzzy": distance.JaroWinkler.normalized_similarity, + "strict": distance.Levenshtein.normalized_similarity, + "edit": distance.Levenshtein.normalized_similarity, + "structure": distance.LCSseq.normalized_similarity, + "fast": distance.Indel.normalized_similarity, + "quick_ratio": fuzz.QRatio, + "weighted_ratio": fuzz.QRatio, +} + + class Similar(object): """ The main class used to search similar words. """ - def __init__(self, needle, haystack): + def __init__( + self, + needle, + haystack, + algo="levenshtein", + score_cutoff=None, + score_hint=None, + processor=None, + top_k=None + ): self.needle = needle self.haystack = haystack + self.processor = processor + self.score_cutoff = score_cutoff + self.score_hint = score_hint + self.top_k = top_k + + if algo not in ALGOS: + raise ValueError(f"Unknown algo: {algo}") + + self.algo = ALGOS[algo] def best(self): """ @@ -23,16 +69,20 @@ def results(self): """ Returns a list of tuple, ordered by similarity. """ - d = dict() - words = [word.strip() for word in self.haystack] + results = process.extract( + self.needle, + [word.strip() for word in self.haystack], + scorer=self.algo, + processor=self.processor, + score_cutoff=self.score_cutoff, + score_hint=self.score_hint, + limit=self.top_k, + ) - if not words: + if not results: raise NoResultException('No similar word found.') - for w in words: - d[w] = Levenshtein.ratio(self.needle, w) - - return sorted(d.items(), key=operator.itemgetter(1), reverse=True) + return [(choice, score) for choice, score, _ in results] def best_match(needle, haystack): From b5e6792f48c6eb76d813aa7f93a736d74b044918 Mon Sep 17 00:00:00 2001 From: DarkRedman Date: Tue, 31 Mar 2026 05:50:03 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore!:=20drop=20legacy=20Python=20support?= =?UTF-8?q?=20(2.x,=203.2=E2=80=933.9)=20and=20require=203.10+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.rst | 2 +- setup.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 610afeb..4501854 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ Usage Installation ------------ -The tool works with Python 2 and Python 3. It can be installed with `Pip` : +The tool works with and Python 3. It can be installed with `Pip` : :: diff --git a/setup.py b/setup.py index 32f917f..81e71f7 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="similar", - version="0.2", + version="1.0.0", url='http://github.com/ncrocfer/similar', author='Nicolas Crocfer', author_email='ncrocfer@gmail.com', @@ -24,11 +24,12 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4' + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14' ), + python_requires=">=3.10", ) \ No newline at end of file