Skip to content

Commit 9364241

Browse files
authored
Merge pull request #247 from derek73/feature/issue-224-matches-comparison-key
Add matches() and comparison_key(); deprecate __eq__/__hash__
2 parents aed108c + 5645fbf commit 9364241

4 files changed

Lines changed: 245 additions & 25 deletions

File tree

docs/release_log.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Release Log
22
===========
33
* 1.3.0 - Unreleased
4+
- Add ``matches()`` and ``comparison_key()`` for explicit name comparison: ``matches()`` compares parsed components case-insensitively (parsing ``str`` arguments first, so ``name.matches("Smith, John")`` and ``name.matches("John Smith")`` both match) and ``comparison_key()`` returns a hashable tuple of the seven components for dedup, dict keys, and sorting (#224)
5+
- Deprecate ``HumanName.__eq__`` and ``__hash__`` for removal in 2.0 (#223): the current design's three promises — case-insensitive equality, equality with plain strings, and hashability — are mutually inconsistent (equal objects can hash differently), equality depends on ``string_format``, and ``maiden`` is invisible to it. Both now emit ``DeprecationWarning`` naming the replacement; behavior is otherwise unchanged until 2.0 (closes #224)
46
- Fix the five non-cached-union ``SetManager``-backed ``Constants`` attributes (``first_name_titles``, ``conjunctions``, ``bound_first_names``, ``non_first_name_prefixes``, ``suffix_acronyms_ambiguous``) accepting non-``SetManager`` assignment silently (e.g. ``constants.conjunctions = 'and'``), degrading membership checks into substring tests with no error; assignment now raises ``TypeError`` like the four cached-union attributes already did (closes #241)
57
- Fix ``HumanName.C`` accepting an invalid ``constants`` value on post-construction assignment (e.g. ``hn.C = 'garbage'``), bypassing the constructor's validation and failing later with an unrelated ``AttributeError``; ``C`` is now a property that validates on assignment too (closes #239)
68
- Fix ``TupleManager`` (and ``RegexTupleManager``) accepting a bare string/bytes argument (raising a cryptic ``dict``-internals ``ValueError``) or an iterable of 2-character strings (silently shredding each into a key/value pair, e.g. ``Constants(capitalization_exceptions=['ii'])`` becoming ``{'i': 'i'}``); both now raise ``TypeError`` with a clear message (closes #242)

docs/usage.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ Requires Python 3.10+.
6565
{'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'nickname': 'John'}
6666
>>> name = HumanName("Dr. Juan Q. Xavier de la Vega III")
6767
>>> name2 = HumanName("de la vega, dr. juan Q. xavier III")
68-
>>> name == name2
68+
>>> name.matches(name2)
69+
True
70+
>>> name.matches("de la vega, dr. juan Q. xavier III")
6971
True
7072
>>> len(name)
7173
5
@@ -74,6 +76,11 @@ Requires Python 3.10+.
7476
>>> name[1:-3]
7577
['Juan', 'Q. Xavier', 'de la Vega']
7678

79+
``name == other`` and ``hash(name)`` are deprecated and will be removed in
80+
2.0; use ``matches()`` for comparison and ``comparison_key()`` for sets,
81+
dicts, and dedup (see `issue #223
82+
<https://github.com/derek73/python-nameparser/issues/223>`_).
83+
7784
Empty or unparsable input does not raise an error; it produces a name whose
7885
attributes are all empty. Check ``len(name) == 0`` (or ``str(name) == ''``)
7986
to detect that nothing was parsed.

nameparser/parser.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
import warnings
23
from collections.abc import Callable, Iterable, Iterator
34
from operator import itemgetter
45
from itertools import groupby
@@ -199,9 +200,22 @@ def __len__(self) -> int:
199200

200201
def __eq__(self, other: object) -> bool:
201202
"""
203+
.. deprecated:: 1.3.0
204+
Removed in 2.0 (see issue #223); use :py:meth:`matches`.
205+
202206
HumanName instances are equal to other objects whose
203-
lower case unicode representation is the same.
204-
"""
207+
lower case unicode representation is the same. Note the
208+
differences from :py:meth:`matches`: this compares formatted
209+
output, so it depends on ``string_format`` and cannot see
210+
``maiden``, and it stringifies operands of any type.
211+
"""
212+
warnings.warn(
213+
"HumanName == comparison is deprecated and will be removed in "
214+
"2.0; use matches() instead. See "
215+
"https://github.com/derek73/python-nameparser/issues/223",
216+
DeprecationWarning,
217+
stacklevel=2,
218+
)
205219
return str(self).lower() == str(other).lower()
206220

207221
@overload
@@ -231,6 +245,18 @@ def __str__(self) -> str:
231245
return " ".join(self)
232246

233247
def __hash__(self) -> int:
248+
"""
249+
.. deprecated:: 1.3.0
250+
Removed in 2.0 (see issue #223); use :py:meth:`comparison_key`
251+
for sets, dicts, and dedup.
252+
"""
253+
warnings.warn(
254+
"hash(HumanName) is deprecated and will be removed in 2.0; use "
255+
"comparison_key() for sets and dicts. See "
256+
"https://github.com/derek73/python-nameparser/issues/223",
257+
DeprecationWarning,
258+
stacklevel=2,
259+
)
234260
# __eq__ compares lowercased strings, so hash the lowercased string
235261
# to keep equal instances in the same hash bucket.
236262
return hash(str(self).lower())
@@ -273,6 +299,69 @@ def as_dict(self, include_empty: bool = True) -> dict[str, str]:
273299
d[m] = val
274300
return d
275301

302+
def comparison_key(self) -> tuple[str, ...]:
303+
"""
304+
The seven name components (title, first, middle, last, suffix,
305+
nickname, maiden) as a lowercased tuple: a canonical, hashable
306+
identity for the parsed name. Use it for dedup, dict keys, and
307+
sorting or grouping, e.g.
308+
``unique = {n.comparison_key(): n for n in names}.values()``.
309+
310+
Built from the ``*_list`` attributes, so it is unaffected by
311+
display settings like ``string_format`` and
312+
``empty_attribute_default``.
313+
314+
Empty or unparsable input yields the all-empty key, so such names
315+
all compare equal and collide in dedup; screen them out with
316+
``len(name) == 0`` first.
317+
318+
.. doctest::
319+
320+
>>> HumanName("Dr. Juan Q. Xavier de la Vega III").comparison_key()
321+
('dr.', 'juan', 'q. xavier', 'de la vega', 'iii', '', '')
322+
323+
"""
324+
return tuple(
325+
" ".join(getattr(self, member + "_list")).lower()
326+
for member in self._members
327+
)
328+
329+
def matches(self, other: 'str | HumanName') -> bool:
330+
"""
331+
Compare parsed components case-insensitively; the semantic
332+
replacement for the deprecated ``==``. A ``str`` argument is parsed
333+
first, using this instance's configuration, so any written form of
334+
the same name matches; a ``HumanName`` argument is compared as
335+
already parsed — its own configuration determined its components.
336+
Two empty or unparsable names match each other; check
337+
``len(name) == 0`` to screen them.
338+
339+
.. doctest::
340+
341+
>>> name = HumanName("Dr. Juan Q. Xavier de la Vega III")
342+
>>> name.matches("de la vega, dr. juan Q. xavier III")
343+
True
344+
>>> name.matches("Juan de la Vega")
345+
False
346+
347+
Unlike the deprecated ``==``, all seven components participate
348+
(including ``maiden``, which the default ``string_format`` omits)
349+
and display settings have no effect. Raises ``TypeError`` for
350+
anything that is not a ``str`` or ``HumanName``; guard optional
351+
values explicitly, e.g. ``x is not None and name.matches(x)``.
352+
353+
Parses string arguments on every call. When matching one name
354+
against many candidates, parse the candidates once or compare
355+
:py:meth:`comparison_key` values instead.
356+
"""
357+
if isinstance(other, HumanName):
358+
return self.comparison_key() == other.comparison_key()
359+
if isinstance(other, str):
360+
return self.comparison_key() == type(self)(other, self.C).comparison_key()
361+
raise TypeError(
362+
f"matches() requires a str or HumanName, got {type(other).__name__}"
363+
)
364+
276365
def _process_initial(self, name_part: str, firstname: bool = False) -> str:
277366
"""
278367
Name parts may include prefixes or conjunctions. This function filters these from the name unless it is

tests/test_python_api.py

Lines changed: 144 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import copy
22
import pickle
33
import re
4+
import warnings
45

56
import pytest
67

@@ -215,18 +216,22 @@ def test_deepcopy_default_name_preserves_singleton_identity(self) -> None:
215216
self.assertEqual(dup.last, hn.last)
216217

217218
def test_comparison(self) -> None:
219+
# deprecated behavior (#223/#224), still supported until 2.0:
220+
# deprecated_call() asserts the warning fires while silencing it
218221
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
219222
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")
220-
self.assertTrue(hn1 == hn2)
221-
self.assertIsNot(hn1, hn2)
222-
self.assertTrue(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC")
223+
with pytest.deprecated_call():
224+
self.assertTrue(hn1 == hn2)
225+
self.assertIsNot(hn1, hn2)
226+
self.assertTrue(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC")
223227
hn1 = HumanName("Doe, Dr. John P., CLU, CFP, LUTC")
224228
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")
225-
self.assertTrue(not hn1 == hn2)
226-
self.assertTrue(not hn1 == 0)
227-
self.assertTrue(not hn1 == "test")
228-
self.assertTrue(not hn1 == ["test"])
229-
self.assertTrue(not hn1 == {"test": hn2})
229+
with pytest.deprecated_call():
230+
self.assertTrue(not hn1 == hn2)
231+
self.assertTrue(not hn1 == 0)
232+
self.assertTrue(not hn1 == "test")
233+
self.assertTrue(not hn1 == ["test"])
234+
self.assertTrue(not hn1 == {"test": hn2})
230235

231236
def test_assignment_to_full_name(self) -> None:
232237
hn = HumanName("John A. Kenneth Doe, Jr.")
@@ -275,30 +280,147 @@ def test_assign_list_to_attribute(self) -> None:
275280
self.m(hn.suffix, "test", hn)
276281

277282
def test_comparison_case_insensitive(self) -> None:
283+
# deprecated behavior (#223/#224), still supported until 2.0
278284
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
279285
hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC")
280-
self.assertTrue(hn1 == hn2)
281-
self.assertIsNot(hn1, hn2)
282-
self.assertTrue(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC")
286+
with pytest.deprecated_call():
287+
self.assertTrue(hn1 == hn2)
288+
self.assertIsNot(hn1, hn2)
289+
self.assertTrue(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC")
283290

284291
def test_hash_matches_case_insensitive_equality(self) -> None:
292+
# deprecated behavior (#223/#224), still supported until 2.0.
285293
# __eq__ compares lowercased strings, so __hash__ must too:
286294
# equal objects are required to have equal hashes.
287295
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
288296
hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC")
289-
self.assertEqual(hn1, hn2)
290-
self.assertEqual(hash(hn1), hash(hn2))
291-
self.assertEqual(len({hn1, hn2}), 1)
292-
# __eq__ also accepts plain strings, so hashing str(self).lower()
293-
# specifically (not e.g. an attribute tuple) is what lets strings and
294-
# HumanName instances interoperate in sets and dicts
295-
hn = HumanName("John Smith")
296-
self.assertEqual(hash(hn), hash("john smith"))
297-
self.assertIn("john smith", {hn})
297+
with pytest.deprecated_call():
298+
self.assertEqual(hn1, hn2)
299+
self.assertEqual(hash(hn1), hash(hn2))
300+
self.assertEqual(len({hn1, hn2}), 1)
301+
# __eq__ also accepts plain strings, so hashing str(self).lower()
302+
# specifically (not e.g. an attribute tuple) is what lets strings
303+
# and HumanName instances interoperate in sets and dicts
304+
hn = HumanName("John Smith")
305+
self.assertEqual(hash(hn), hash("john smith"))
306+
self.assertIn("john smith", {hn})
298307

299308
def test_not_equal_operator(self) -> None:
300-
self.assertTrue(HumanName("John Smith") != HumanName("Jane Smith"))
301-
self.assertFalse(HumanName("John Smith") != HumanName("john smith"))
309+
# deprecated behavior (#223/#224), still supported until 2.0;
310+
# != routes through __eq__, so it warns too
311+
with pytest.deprecated_call():
312+
self.assertTrue(HumanName("John Smith") != HumanName("Jane Smith"))
313+
self.assertFalse(HumanName("John Smith") != HumanName("john smith"))
314+
315+
def test_comparison_key_components(self) -> None:
316+
hn = HumanName("Dr. Juan Q. Xavier de la Vega III")
317+
self.assertEqual(
318+
hn.comparison_key(),
319+
('dr.', 'juan', 'q. xavier', 'de la vega', 'iii', '', ''))
320+
321+
def test_comparison_key_case_insensitive_across_formats(self) -> None:
322+
hn1 = HumanName("Dr. Juan Q. Xavier de la Vega III")
323+
hn2 = HumanName("de la vega, dr. juan Q. xavier III")
324+
self.assertEqual(hn1.comparison_key(), hn2.comparison_key())
325+
326+
def test_comparison_key_independent_of_string_format(self) -> None:
327+
# unlike ==, which compares str(self) and so changes meaning when
328+
# display config changes, the key is built from the parsed lists
329+
hn1 = HumanName("John Smith")
330+
hn2 = HumanName("John Smith", string_format="{last}")
331+
self.assertEqual(hn1.comparison_key(), hn2.comparison_key())
332+
333+
def test_comparison_key_includes_maiden(self) -> None:
334+
# maiden isn't in the default string_format, so == can't see it;
335+
# the key includes all seven members
336+
hn1 = HumanName(first="Jenny", last="Baker", maiden="Johnson")
337+
hn2 = HumanName(first="Jenny", last="Baker")
338+
self.assertNotEqual(hn1.comparison_key(), hn2.comparison_key())
339+
340+
def test_comparison_key_usable_for_dedup(self) -> None:
341+
names = [HumanName("John Smith"), HumanName("Smith, John"),
342+
HumanName("JOHN SMITH"), HumanName("Jane Smith")]
343+
unique = {n.comparison_key(): n for n in names}
344+
self.assertEqual(len(unique), 2)
345+
346+
def test_matches_str_is_semantic_not_textual(self) -> None:
347+
# any written form of the same name matches, unlike == which only
348+
# matches strings that render exactly like str(self)
349+
hn = HumanName("Dr. Juan Q. Xavier de la Vega III")
350+
self.assertTrue(hn.matches("de la vega, dr. juan Q. xavier III"))
351+
self.assertTrue(hn.matches("Dr. Juan Q. Xavier de la Vega III"))
352+
self.assertFalse(hn.matches("Juan de la Vega"))
353+
354+
def test_matches_humanname_operand(self) -> None:
355+
hn = HumanName("John Smith")
356+
self.assertTrue(hn.matches(HumanName("JOHN SMITH")))
357+
self.assertFalse(hn.matches(HumanName("Jane Smith")))
358+
359+
def test_matches_parses_str_with_instance_constants(self) -> None:
360+
# the custom title must not be a default one ('chancellor' is!), or
361+
# this passes without the self.C parse path ever mattering
362+
c = Constants()
363+
c.titles.add('zephyrmark')
364+
self.assertNotIn('zephyrmark', CONSTANTS.titles)
365+
hn = HumanName("Zephyrmark Jane Smith", constants=c)
366+
# the str operand is parsed with self.C, so the custom title is
367+
# recognized in the comma form; parsed with the shared CONSTANTS,
368+
# 'zephyrmark' would land in first/middle and the keys would differ
369+
self.assertTrue(hn.matches("smith, zephyrmark jane"))
370+
371+
def test_matches_humanname_operand_keeps_its_own_parse(self) -> None:
372+
# asymmetry, pinned deliberately: a str operand is reparsed with
373+
# self.C, but a HumanName operand is compared as already parsed --
374+
# its own constants determined its components
375+
c = Constants()
376+
c.titles.add('zephyrmark')
377+
with_title = HumanName("Zephyrmark Jane Smith", constants=c)
378+
default_parse = HumanName("Zephyrmark Jane Smith")
379+
self.assertTrue(with_title.matches("Zephyrmark Jane Smith"))
380+
self.assertFalse(with_title.matches(default_parse))
381+
382+
def test_empty_parses_share_a_comparison_key(self) -> None:
383+
# documented caveat: empty/unparsable input collapses to the
384+
# all-empty key, so such names match each other and collide in
385+
# dedup; screen with len(name) == 0 first
386+
self.assertTrue(HumanName("").matches(HumanName(",")))
387+
self.assertEqual(HumanName("()").comparison_key(),
388+
HumanName("").comparison_key())
389+
390+
def test_matches_non_ascii_case_insensitive(self) -> None:
391+
hn = HumanName("JOSÉ GARCÍA")
392+
self.assertTrue(hn.matches("José García"))
393+
394+
def test_matches_rejects_other_types(self) -> None:
395+
hn = HumanName("John Smith")
396+
for bad in (None, 42, b"John Smith", ["John Smith"]):
397+
with pytest.raises(TypeError, match="str or HumanName"):
398+
hn.matches(bad) # type: ignore[arg-type]
399+
400+
def test_eq_emits_deprecation_warning(self) -> None:
401+
# behavior is unchanged until 2.0 (#223); 1.3.0 only warns
402+
hn1 = HumanName("John Smith")
403+
hn2 = HumanName("john smith")
404+
with pytest.deprecated_call(match="matches"):
405+
result = hn1 == hn2
406+
self.assertTrue(result)
407+
408+
def test_hash_emits_deprecation_warning(self) -> None:
409+
hn = HumanName("John Smith")
410+
with pytest.deprecated_call(match="comparison_key"):
411+
result = hash(hn)
412+
self.assertEqual(result, hash("john smith"))
413+
414+
def test_new_comparison_api_does_not_warn(self) -> None:
415+
# the replacements must be adoptable before 2.0 without tripping
416+
# -W error test suites; matches(str) parses, so this also covers
417+
# the parse path
418+
hn = HumanName("John Smith")
419+
with warnings.catch_warnings():
420+
warnings.simplefilter("error")
421+
hn.comparison_key()
422+
hn.matches("Smith, John")
423+
hn.matches(HumanName("John Smith"))
302424

303425
def test_unparsable_attribute_removed(self) -> None:
304426
# Removed in 1.3.0: the guard that reported unparsable names was

0 commit comments

Comments
 (0)