Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Sequence
from .legal_conflict_name_match_types import NameMatch
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .text_normalise_name import normalise_name ← from text.normalise-name ^1.0.0 · built alongside by fune
_COMPANY_FORMS = frozenset(["ltd", "limited", "plc", "llp", "llc", "inc"])
_DROPPED = frozenset(["'", "’", "."])
_MAX_KEY = 500
def _lower_one(ch: str) -> str:
# One-to-one case mappings only, as text.normalise-name, so all three languages agree.
mapped = ch.lower()
return mapped if len(mapped) == 1 else ch
def _is_ascii_punctuation(cp: int) -> bool:
return 0x21 <= cp <= 0x2F or 0x3A <= cp <= 0x40 or 0x5B <= cp <= 0x60 or 0x7B <= cp <= 0x7E
def _match_key(value: str) -> str:
"""The normalised, punctuation-free, company-form-free, token-sorted key."""
parts: List[str] = []
for ch in normalise_name(value):
if ch in _DROPPED:
continue
if ch == "&":
parts.append(" and ")
elif _is_ascii_punctuation(ord(ch)) or ch == " ":
parts.append(" ")
else:
parts.append(_lower_one(ch))
tokens = [t for t in "".join(parts).split(" ") if t != "" and t not in _COMPANY_FORMS]
key = " ".join(sorted(tokens))
if len(key) > _MAX_KEY:
raise ValueError("names must be at most %d characters after normalising" % (_MAX_KEY,))
return key
def _jaro_winkler(s1: str, s2: str) -> int:
"""Jaro-Winkler similarity in basis points, from exact integer fractions."""
a = len(s1)
b = len(s2)
if a == 0 or b == 0:
return 0
window = max(0, max(a, b) // 2 - 1)
used = [False] * b
order1: List[str] = []
for i in range(a):
for j in range(max(0, i - window), min(b - 1, i + window) + 1):
if not used[j] and s1[i] == s2[j]:
used[j] = True
order1.append(s1[i])
break
m = len(order1)
if m == 0:
return 0
k = 0
out_of_order = 0
for j in range(b):
if not used[j]:
continue
if s2[j] != order1[k]:
out_of_order += 1
k += 1
# Jaro = n / d exactly, with t = out_of_order / 2.
n = 2 * m * m * (a + b) + a * b * (2 * m - out_of_order)
d = 6 * a * b * m
prefix = 0
while prefix < 4 and prefix < a and prefix < b and s1[prefix] == s2[prefix]:
prefix += 1
if 10 * n <= 7 * d:
return round_div(10000 * n, d, "half-up")
return round_div(10000 * (n * (10 - prefix) + prefix * d), 10 * d, "half-up")
def conflict_name_match(name: str, candidates: Sequence[str], threshold_basis_points: int) -> List[NameMatch]:
"""Candidates similar to a name, best first, for a conflict check."""
if (
isinstance(threshold_basis_points, bool)
or not isinstance(threshold_basis_points, int)
or threshold_basis_points < 0
or threshold_basis_points > 10000
):
raise ValueError("thresholdBasisPoints must be between 0 and 10000, received %s" % (threshold_basis_points,))
key = _match_key(name)
if key == "":
raise ValueError("name is empty after normalising")
matches: List[NameMatch] = []
for index, candidate in enumerate(candidates):
other = _match_key(candidate)
score = _jaro_winkler(key, other)
if score >= threshold_basis_points:
matches.append(NameMatch(index=index, candidate=candidate, match_key=other, score=score))
matches.sort(key=lambda x: (-x.score, x.index))
return matches