Skip to content

Commit 92cf216

Browse files
derek73claude
andcommitted
Deprecate __eq__ and __hash__ for removal in 2.0
Both emit DeprecationWarning naming the replacement (matches() / comparison_key()); behavior is unchanged until 2.0. Rationale lives in issue #223: case-insensitive equality, equality with plain strings, and hashability are mutually inconsistent promises (equal objects can hash differently), equality depends on string_format, and maiden is invisible to it. Existing eq/hash tests now assert the warning fires via pytest.deprecated_call(), keeping the suite noise-free while still covering the deprecated behavior. usage.rst's equality example moves to matches() with a deprecation note. Closes #224 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e697b2 commit 92cf216

4 files changed

Lines changed: 96 additions & 25 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ Release Log
22
===========
33
* 1.3.0 - Unreleased
44
- 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)
56
- 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)
67
- 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)
78
- 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: 28 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())

tests/test_python_api.py

Lines changed: 59 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,37 @@ 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"))
302314

303315
def test_comparison_key_components(self) -> None:
304316
hn = HumanName("Dr. Juan Q. Xavier de la Vega III")
@@ -359,6 +371,31 @@ def test_matches_rejects_other_types(self) -> None:
359371
with pytest.raises(TypeError, match="str or HumanName"):
360372
hn.matches(bad) # type: ignore[arg-type]
361373

374+
def test_eq_emits_deprecation_warning(self) -> None:
375+
# behavior is unchanged until 2.0 (#223); 1.3.0 only warns
376+
hn1 = HumanName("John Smith")
377+
hn2 = HumanName("john smith")
378+
with pytest.deprecated_call(match="matches"):
379+
result = hn1 == hn2
380+
self.assertTrue(result)
381+
382+
def test_hash_emits_deprecation_warning(self) -> None:
383+
hn = HumanName("John Smith")
384+
with pytest.deprecated_call(match="comparison_key"):
385+
result = hash(hn)
386+
self.assertEqual(result, hash("john smith"))
387+
388+
def test_new_comparison_api_does_not_warn(self) -> None:
389+
# the replacements must be adoptable before 2.0 without tripping
390+
# -W error test suites; matches(str) parses, so this also covers
391+
# the parse path
392+
hn = HumanName("John Smith")
393+
with warnings.catch_warnings():
394+
warnings.simplefilter("error")
395+
hn.comparison_key()
396+
hn.matches("Smith, John")
397+
hn.matches(HumanName("John Smith"))
398+
362399
def test_unparsable_attribute_removed(self) -> None:
363400
# Removed in 1.3.0: the guard that reported unparsable names was
364401
# unreachable, so the attribute was always False after any parse.

0 commit comments

Comments
 (0)