Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ build
*.egg-info
.idea
.tox
.coverage
.pytest_cache
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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` :

::

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
python-Levenshtein>=0.12.0
rapidfuzz>=3.5.2
13 changes: 7 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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",
)
2 changes: 0 additions & 2 deletions similar/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
# -*- coding: utf-8 -*

from .similar import Similar # noqa
from .similar import best_match # noqa
2 changes: 0 additions & 2 deletions similar/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-

"""
This module contains the list of similar exceptions
"""
Expand Down
70 changes: 59 additions & 11 deletions similar/similar.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,62 @@
# -*- coding: utf-8 -*

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):
"""
Expand All @@ -25,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):
Expand Down
49 changes: 0 additions & 49 deletions test_similar.py

This file was deleted.

8 changes: 0 additions & 8 deletions test_wordlist.txt

This file was deleted.

88 changes: 88 additions & 0 deletions tests/test_similar.py
Original file line number Diff line number Diff line change
@@ -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"