Skip to content

Commit 8e697b2

Browse files
derek73claude
andcommitted
Add comparison_key() and matches() for explicit name comparison
comparison_key() returns the seven parsed components as a lowercased tuple — a canonical, hashable identity built from the *_list attributes, so it is independent of string_format/empty_attribute_default and includes maiden, which the default string_format omits. matches() compares keys, parsing str arguments with the instance's own Constants, so any written form of the same name matches ("Smith, John" and "John Smith"), and raises TypeError for other types. These are the replacements for __eq__/__hash__ ahead of their 2.0 removal (#223); the deprecation warnings land separately. Part of #224. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent aed108c commit 8e697b2

3 files changed

Lines changed: 116 additions & 0 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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)
45
- 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)
56
- 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)
67
- 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)

nameparser/parser.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,62 @@ def as_dict(self, include_empty: bool = True) -> dict[str, str]:
273273
d[m] = val
274274
return d
275275

276+
def comparison_key(self) -> tuple[str, ...]:
277+
"""
278+
The seven name components (title, first, middle, last, suffix,
279+
nickname, maiden) as a lowercased tuple: a canonical, hashable
280+
identity for the parsed name. Use it for dedup, dict keys, and
281+
sorting or grouping, e.g.
282+
``unique = {n.comparison_key(): n for n in names}.values()``.
283+
284+
Built from the ``*_list`` attributes, so it is unaffected by
285+
display settings like ``string_format`` and
286+
``empty_attribute_default``.
287+
288+
.. doctest::
289+
290+
>>> HumanName("Dr. Juan Q. Xavier de la Vega III").comparison_key()
291+
('dr.', 'juan', 'q. xavier', 'de la vega', 'iii', '', '')
292+
293+
"""
294+
return tuple(
295+
" ".join(getattr(self, member + "_list")).lower()
296+
for member in self._members
297+
)
298+
299+
def matches(self, other: 'str | HumanName') -> bool:
300+
"""
301+
Compare parsed components case-insensitively; the semantic
302+
replacement for the deprecated ``==``. A ``str`` argument is parsed
303+
first, using this instance's configuration, so any written form of
304+
the same name matches:
305+
306+
.. doctest::
307+
308+
>>> name = HumanName("Dr. Juan Q. Xavier de la Vega III")
309+
>>> name.matches("de la vega, dr. juan Q. xavier III")
310+
True
311+
>>> name.matches("Juan de la Vega")
312+
False
313+
314+
Unlike the deprecated ``==``, all seven components participate
315+
(including ``maiden``, which the default ``string_format`` omits)
316+
and display settings have no effect. Raises ``TypeError`` for
317+
anything that is not a ``str`` or ``HumanName``; guard optional
318+
values explicitly, e.g. ``x is not None and name.matches(x)``.
319+
320+
Parses string arguments on every call. When matching one name
321+
against many candidates, parse the candidates once or compare
322+
:py:meth:`comparison_key` values instead.
323+
"""
324+
if isinstance(other, HumanName):
325+
return self.comparison_key() == other.comparison_key()
326+
if isinstance(other, str):
327+
return self.comparison_key() == type(self)(other, self.C).comparison_key()
328+
raise TypeError(
329+
f"matches() requires a str or HumanName, got {type(other).__name__}"
330+
)
331+
276332
def _process_initial(self, name_part: str, firstname: bool = False) -> str:
277333
"""
278334
Name parts may include prefixes or conjunctions. This function filters these from the name unless it is

tests/test_python_api.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,65 @@ def test_not_equal_operator(self) -> None:
300300
self.assertTrue(HumanName("John Smith") != HumanName("Jane Smith"))
301301
self.assertFalse(HumanName("John Smith") != HumanName("john smith"))
302302

303+
def test_comparison_key_components(self) -> None:
304+
hn = HumanName("Dr. Juan Q. Xavier de la Vega III")
305+
self.assertEqual(
306+
hn.comparison_key(),
307+
('dr.', 'juan', 'q. xavier', 'de la vega', 'iii', '', ''))
308+
309+
def test_comparison_key_case_insensitive_across_formats(self) -> None:
310+
hn1 = HumanName("Dr. Juan Q. Xavier de la Vega III")
311+
hn2 = HumanName("de la vega, dr. juan Q. xavier III")
312+
self.assertEqual(hn1.comparison_key(), hn2.comparison_key())
313+
314+
def test_comparison_key_independent_of_string_format(self) -> None:
315+
# unlike ==, which compares str(self) and so changes meaning when
316+
# display config changes, the key is built from the parsed lists
317+
hn1 = HumanName("John Smith")
318+
hn2 = HumanName("John Smith", string_format="{last}")
319+
self.assertEqual(hn1.comparison_key(), hn2.comparison_key())
320+
321+
def test_comparison_key_includes_maiden(self) -> None:
322+
# maiden isn't in the default string_format, so == can't see it;
323+
# the key includes all seven members
324+
hn1 = HumanName(first="Jenny", last="Baker", maiden="Johnson")
325+
hn2 = HumanName(first="Jenny", last="Baker")
326+
self.assertNotEqual(hn1.comparison_key(), hn2.comparison_key())
327+
328+
def test_comparison_key_usable_for_dedup(self) -> None:
329+
names = [HumanName("John Smith"), HumanName("Smith, John"),
330+
HumanName("JOHN SMITH"), HumanName("Jane Smith")]
331+
unique = {n.comparison_key(): n for n in names}
332+
self.assertEqual(len(unique), 2)
333+
334+
def test_matches_str_is_semantic_not_textual(self) -> None:
335+
# any written form of the same name matches, unlike == which only
336+
# matches strings that render exactly like str(self)
337+
hn = HumanName("Dr. Juan Q. Xavier de la Vega III")
338+
self.assertTrue(hn.matches("de la vega, dr. juan Q. xavier III"))
339+
self.assertTrue(hn.matches("Dr. Juan Q. Xavier de la Vega III"))
340+
self.assertFalse(hn.matches("Juan de la Vega"))
341+
342+
def test_matches_humanname_operand(self) -> None:
343+
hn = HumanName("John Smith")
344+
self.assertTrue(hn.matches(HumanName("JOHN SMITH")))
345+
self.assertFalse(hn.matches(HumanName("Jane Smith")))
346+
347+
def test_matches_parses_str_with_instance_constants(self) -> None:
348+
c = Constants()
349+
c.titles.add('chancellor')
350+
hn = HumanName("Chancellor Jane Smith", constants=c)
351+
# the str operand is parsed with self.C, so the custom title is
352+
# recognized in the comma form; the shared CONSTANTS would not
353+
# parse 'chancellor' as a title here
354+
self.assertTrue(hn.matches("smith, chancellor jane"))
355+
356+
def test_matches_rejects_other_types(self) -> None:
357+
hn = HumanName("John Smith")
358+
for bad in (None, 42, b"John Smith", ["John Smith"]):
359+
with pytest.raises(TypeError, match="str or HumanName"):
360+
hn.matches(bad) # type: ignore[arg-type]
361+
303362
def test_unparsable_attribute_removed(self) -> None:
304363
# Removed in 1.3.0: the guard that reported unparsable names was
305364
# unreachable, so the attribute was always False after any parse.

0 commit comments

Comments
 (0)