From 4eaeee9d83617e43222852daec9caf5cff680cbb Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 5 Jul 2026 23:10:54 -0700 Subject: [PATCH 1/2] Add 2.0 deprecation warnings for bytes input and SetManager legacy surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.3.0 is the bridge release, so every decided 2.0 removal warns in it: - bytes to HumanName/full_name or SetManager.add()/add_with_encoding() warns with a decode-first hint (#245); the encoding kwarg is deprecated with it - SetManager.__call__ warns: it returns the raw underlying set, and mutating that bypasses normalization and cache invalidation (#243) - SetManager.remove() of a missing member warns (raises KeyError in 2.0, matching set.remove); new discard() is the intentional ignore-missing spelling, wired to the same cache invalidation (#243) The option-A-only #243 removals (operators, the Set ABC) deliberately do not warn — that decision is still open for feedback on the issue. Present-member remove(), str input, and everything else stay silent; suite is warning-noise-free. Co-Authored-By: Claude Fable 5 --- docs/customize.rst | 2 +- docs/release_log.rst | 3 ++ nameparser/config/__init__.py | 56 ++++++++++++++++++++++++++++++++++ nameparser/parser.py | 11 +++++++ tests/test_constants.py | 57 +++++++++++++++++++++++++++++++++-- tests/test_python_api.py | 21 ++++++++++++- 6 files changed, 146 insertions(+), 4 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index d49ea8c..cb2be8c 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -53,7 +53,7 @@ Editable attributes of nameparser.config.CONSTANTS * :py:data:`~nameparser.config.CAPITALIZATION_EXCEPTIONS` - Dictionary of pieces that do not capitalize the first letter, e.g. "Ph.D". * :py:data:`~nameparser.config.REGEXES` - Regular expressions used to find words, initials, nicknames, etc. -Each set-valued constant comes with :py:func:`~nameparser.config.SetManager.add` and :py:func:`~nameparser.config.SetManager.remove` methods for tuning +Each set-valued constant comes with :py:func:`~nameparser.config.SetManager.add`, :py:func:`~nameparser.config.SetManager.remove`, and :py:func:`~nameparser.config.SetManager.discard` methods for tuning the constants for your project. These methods automatically lower case and remove punctuation to normalize them for comparison. The two dict-valued constants (``CAPITALIZATION_EXCEPTIONS`` and ``REGEXES``) are edited with diff --git a/docs/release_log.rst b/docs/release_log.rst index 918185e..1783a7a 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -5,6 +5,9 @@ Release Log **Breaking Changes & Deprecations** - 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) + - Deprecate ``bytes`` input for removal in 2.0 (#245): passing ``bytes`` to ``HumanName``/``full_name`` or to ``SetManager.add()``/``add_with_encoding()`` now emits ``DeprecationWarning`` — decode first, e.g. ``value.decode('utf-8')``. The ``encoding`` constructor argument is deprecated with it + - Deprecate ``SetManager.__call__`` for removal in 2.0 (#243): calling a manager returns the raw underlying set, so mutating the result bypasses normalization and cache invalidation; iterate the manager or copy with ``set(manager)`` instead + - Add ``SetManager.discard()``, and deprecate ``remove()`` of a *missing* member (#243): it currently does nothing but will raise ``KeyError`` in 2.0, matching ``set.remove``; use ``discard()`` for intentional ignore-missing removal. Removing present members is unchanged and does not warn - Fix ``HumanName`` acting as its own iterator with a stored cursor: breaking out of a loop, iterating in nested loops, or calling ``len(name)`` mid-loop corrupted subsequent iteration; ``iter(name)`` now returns a fresh independent iterator each time. ``next(name)`` on the instance itself (undocumented) now raises ``TypeError`` — call ``next(iter(name))`` instead (closes #225) - Remove the vestigial ``unparsable`` attribute: the guard that was meant to set it has been unreachable since 2013 (v0.2.9), so it has reported ``False`` for every parsed name for over a decade; check ``len(name) == 0`` to detect an empty parse - Remove ``__ne__``; Python 3 derives ``!=`` from ``__eq__`` automatically diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index d6a2f13..dcb705a 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -28,6 +28,7 @@ """ import re import sys +import warnings from collections.abc import Callable, Iterable, Iterator, Mapping, Set from typing import Any, TypeVar, overload @@ -137,6 +138,20 @@ def __init__(self, elements: Iterable[str]) -> None: self._on_change = None def __call__(self) -> Set[str]: + """ + .. deprecated:: 1.3.0 + Removed in 2.0 (see issue #243). Returns the raw underlying set, + so mutating it bypasses normalization and cache invalidation; + iterate the manager or copy with ``set(manager)`` instead. + """ + warnings.warn( + "Calling a SetManager to get the raw underlying set is " + "deprecated and will be removed in 2.0; iterate the manager or " + "copy it with set(manager) instead. See " + "https://github.com/derek73/python-nameparser/issues/243", + DeprecationWarning, + stacklevel=2, + ) return self.elements def __repr__(self) -> str: @@ -198,12 +213,24 @@ def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an explicit `encoding` parameter to specify the encoding of binary strings that are not DEFAULT_ENCODING (UTF-8). + + .. deprecated:: 1.3.0 + ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue + #245); decode before adding. """ stdin_encoding = None if sys.stdin: stdin_encoding = sys.stdin.encoding encoding = encoding or stdin_encoding or DEFAULT_ENCODING if isinstance(s, bytes): + warnings.warn( + "Passing bytes to SetManager.add()/add_with_encoding() is " + "deprecated and will raise TypeError in 2.0; decode it " + "first, e.g. value.decode('utf-8'). See " + "https://github.com/derek73/python-nameparser/issues/245", + DeprecationWarning, + stacklevel=2, + ) s = s.decode(encoding) normalized = lc(s) if normalized not in self.elements: @@ -225,6 +252,35 @@ def remove(self, *strings: str) -> Self: """ Remove the lower case and no-period version of the string arguments from the set. Returns ``self`` for chaining. + + .. deprecated:: 1.3.0 + Removing a *missing* member currently does nothing but will + raise ``KeyError`` in 2.0, matching ``set.remove`` (see issue + #243); use :py:func:`discard` to ignore missing members. + """ + changed = False + for s in strings: + if (lower := lc(s)) in self.elements: + self.elements.remove(lower) + changed = True + else: + warnings.warn( + "SetManager.remove() of a missing member currently does " + "nothing, but will raise KeyError in 2.0; use discard() " + "to ignore missing members. See " + "https://github.com/derek73/python-nameparser/issues/243", + DeprecationWarning, + stacklevel=2, + ) + if changed and self._on_change: + self._on_change() + return self + + def discard(self, *strings: str) -> Self: + """ + Remove the lower case and no-period version of the string arguments + from the set if present; missing members are ignored, like + ``set.discard``. Returns ``self`` for chaining. """ changed = False for s in strings: diff --git a/nameparser/parser.py b/nameparser/parser.py index 30602e2..24e8821 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -54,6 +54,8 @@ class HumanName: honored). Pass ``None`` for `per-instance config `_. Anything else raises ``TypeError``. :param str encoding: string representing the encoding of your input + (deprecated with ``bytes`` input, removal in 2.0 — decode before + passing; see issue #245) :param str string_format: python string formatting :param str initials_format: python initials string formatting :param str initials_delimter: string delimiter for initials @@ -889,6 +891,15 @@ def full_name(self, value: str | bytes) -> None: self.original = value if isinstance(value, bytes): + # deprecated 1.3.0, raises TypeError in 2.0 (#245) + warnings.warn( + "Passing bytes to HumanName is deprecated and will raise " + "TypeError in 2.0; decode it first, e.g. " + "value.decode('utf-8'). See " + "https://github.com/derek73/python-nameparser/issues/245", + DeprecationWarning, + stacklevel=2, + ) self._full_name = value.decode(self.encoding) else: self._full_name = value diff --git a/tests/test_constants.py b/tests/test_constants.py index 0296053..5d84bc4 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -2,6 +2,7 @@ import pickle import re import timeit +import warnings from typing import Any import pytest @@ -332,10 +333,61 @@ def test_none_empty_attribute_string_formatting(self) -> None: self.assertEqual('', str(hn), hn) def test_add_constant_with_explicit_encoding(self) -> None: + # bytes input is deprecated (#245), still supported until 2.0 c = Constants() - c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') + with pytest.deprecated_call(): + c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') self.assertIn('béck', c.titles) + def test_set_manager_add_bytes_emits_deprecation_warning(self) -> None: + # bytes elements are removed in 2.0 (#245); the caller should decode + sm = SetManager(['dr']) + with pytest.deprecated_call(match="decode"): + sm.add(b'esq') # type: ignore[arg-type] # deliberately deprecated input + self.assertIn('esq', sm) + + def test_set_manager_add_str_does_not_warn(self) -> None: + sm = SetManager(['dr']) + with warnings.catch_warnings(): + warnings.simplefilter("error") + sm.add('esq') + self.assertIn('esq', sm) + + def test_set_manager_call_emits_deprecation_warning(self) -> None: + # __call__ hands out the raw underlying set, bypassing normalization + # and cache invalidation; removed in 2.0 (#243) + sm = SetManager(['dr']) + with pytest.deprecated_call(match="set"): + elements = sm() + self.assertEqual(elements, {'dr'}) + + def test_set_manager_discard_ignores_missing_without_warning(self) -> None: + sm = SetManager(['dr', 'mr']) + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = sm.discard('nope').discard('Dr.') # normalizes like remove() + self.assertIs(result, sm) + self.assertEqual(set(sm), {'mr'}) + + def test_set_manager_discard_invalidates_cached_union(self) -> None: + c = Constants() + self.assertIn('hon', c.suffixes_prefixes_titles) # prime the cache + c.titles.discard('hon') + self.assertNotIn('hon', c.suffixes_prefixes_titles) + + def test_set_manager_remove_missing_member_emits_deprecation_warning(self) -> None: + # ignore-missing remove() becomes KeyError in 2.0 (#243); discard() + # is the intentional ignore-missing spelling + sm = SetManager(['dr']) + with pytest.deprecated_call(match="discard"): + sm.remove('nope') + self.assertEqual(set(sm), {'dr'}) + # removing a present member stays silent + with warnings.catch_warnings(): + warnings.simplefilter("error") + sm.remove('dr') + self.assertEqual(len(sm), 0) + def test_pickle_roundtrip_preserves_customizations(self) -> None: """A pickled Constants must restore its customized collections. @@ -622,7 +674,8 @@ def test_suffixes_prefixes_titles_reflects_add_with_encoding(self) -> None: """add_with_encoding must invalidate the cache like add()/remove() do.""" c = Constants() _ = c.suffixes_prefixes_titles # prime the cache - c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') + with pytest.deprecated_call(): # bytes input deprecated (#245) + c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') self.assertIn('béck', c.suffixes_prefixes_titles) def test_suffixes_prefixes_titles_reflects_replaced_manager(self) -> None: diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 063780b..86df1a0 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -28,10 +28,29 @@ def test_string_output(self) -> None: self.m(str(hn), "Jüan de la Véña", hn) def test_escaped_utf8_bytes(self) -> None: - hn = HumanName(b'B\xc3\xb6ck, Gerald') + # bytes input is deprecated (#245), still supported until 2.0 + with pytest.deprecated_call(): + hn = HumanName(b'B\xc3\xb6ck, Gerald') self.m(hn.first, "Gerald", hn) self.m(hn.last, "Böck", hn) + def test_bytes_full_name_emits_deprecation_warning(self) -> None: + # bytes input is removed in 2.0 (#245); the caller should decode + with pytest.deprecated_call(match="decode"): + hn = HumanName(b'John Smith') + self.m(hn.first, "John", hn) + hn2 = HumanName("Jane Doe") + with pytest.deprecated_call(match="decode"): + hn2.full_name = b'John Smith' + self.m(hn2.first, "John", hn2) + + def test_str_full_name_does_not_warn(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + hn = HumanName("John Smith") + hn.full_name = "Jane Doe" + self.m(hn.first, "Jane", hn) + def test_len(self) -> None: hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(len(hn), 5, hn) From 00440733d98ad23580bca52f984f073e9e068bce Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 5 Jul 2026 23:26:46 -0700 Subject: [PATCH 2/2] Fix stacklevel misattribution on delegated bytes-deprecation warnings SetManager.add()/add_with_encoding() and HumanName's constructor/full_name setter each had a fixed stacklevel that only pointed at the real caller when the bytes-deprecation warning fired on the direct-call path. Going through the wrapper (add() -> add_with_encoding(), __init__ -> setter) added a stack frame, so the warning was misattributed to nameparser's own internals instead of the caller's code. Extract the shared decode+warn logic into a private helper (_add_normalized / _apply_full_name) that each public entry point calls directly with its own correct stacklevel, verified empirically that both call paths now attribute to the user's line. Also add the missing .. deprecated:: note to add()'s docstring, tighten a weak match= test assertion, fix present-tense wording in test comments, and add a test for remove()/discard() with mixed present+missing members in one call. Co-Authored-By: Claude Sonnet 5 --- nameparser/config/__init__.py | 35 +++++++++++++++++++++++------------ nameparser/parser.py | 15 ++++++++++++--- tests/test_constants.py | 24 +++++++++++++++++++++--- tests/test_python_api.py | 2 +- 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index dcb705a..c455405 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -208,16 +208,11 @@ def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[overrid __rxor__ = __xor__ - def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None: - """ - Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an - explicit `encoding` parameter to specify the encoding of binary strings that - are not DEFAULT_ENCODING (UTF-8). - - .. deprecated:: 1.3.0 - ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue - #245); decode before adding. - """ + def _add_normalized(self, s: str | bytes, encoding: str | None, *, stacklevel: int) -> None: + # Shared by add() and add_with_encoding() so each can call it + # directly with a stacklevel that attributes the warning to *its own* + # caller -- add() delegating to add_with_encoding() would otherwise + # add a frame and misattribute the warning to this module. stdin_encoding = None if sys.stdin: stdin_encoding = sys.stdin.encoding @@ -229,7 +224,7 @@ def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None "first, e.g. value.decode('utf-8'). See " "https://github.com/derek73/python-nameparser/issues/245", DeprecationWarning, - stacklevel=2, + stacklevel=stacklevel, ) s = s.decode(encoding) normalized = lc(s) @@ -238,13 +233,29 @@ def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None if self._on_change: self._on_change() + def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None: + """ + Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an + explicit `encoding` parameter to specify the encoding of binary strings that + are not DEFAULT_ENCODING (UTF-8). + + .. deprecated:: 1.3.0 + ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue + #245); decode before adding. + """ + self._add_normalized(s, encoding, stacklevel=3) + def add(self, *strings: str) -> Self: """ Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set. Returns ``self`` for chaining. + + .. deprecated:: 1.3.0 + ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue + #245); decode before adding. """ for s in strings: - self.add_with_encoding(s) + self._add_normalized(s, None, stacklevel=3) return self diff --git a/nameparser/parser.py b/nameparser/parser.py index 24e8821..1fd8fd0 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -137,8 +137,10 @@ def __init__( self.nickname = nickname self.maiden = maiden else: - # full_name setter triggers the parse - self.full_name = full_name + # calls _apply_full_name directly (not the setter) so the + # deprecation warning below attributes to this constructor's + # caller rather than to the setter + self._apply_full_name(full_name, stacklevel=3) @staticmethod def _validate_constants(constants: 'Constants | None') -> 'Constants': @@ -888,6 +890,13 @@ def full_name(self) -> str: @full_name.setter def full_name(self, value: str | bytes) -> None: + self._apply_full_name(value, stacklevel=3) + + def _apply_full_name(self, value: str | bytes, *, stacklevel: int) -> None: + # Shared by the setter and the constructor so each can call it + # directly with a stacklevel that attributes the warning to *its own* + # caller -- the constructor going through the setter would otherwise + # add a frame and misattribute the warning to this module. self.original = value if isinstance(value, bytes): @@ -898,7 +907,7 @@ def full_name(self, value: str | bytes) -> None: "value.decode('utf-8'). See " "https://github.com/derek73/python-nameparser/issues/245", DeprecationWarning, - stacklevel=2, + stacklevel=stacklevel, ) self._full_name = value.decode(self.encoding) else: diff --git a/tests/test_constants.py b/tests/test_constants.py index 5d84bc4..6f65125 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -340,7 +340,7 @@ def test_add_constant_with_explicit_encoding(self) -> None: self.assertIn('béck', c.titles) def test_set_manager_add_bytes_emits_deprecation_warning(self) -> None: - # bytes elements are removed in 2.0 (#245); the caller should decode + # bytes elements will be removed in 2.0 (#245); the caller should decode sm = SetManager(['dr']) with pytest.deprecated_call(match="decode"): sm.add(b'esq') # type: ignore[arg-type] # deliberately deprecated input @@ -355,9 +355,9 @@ def test_set_manager_add_str_does_not_warn(self) -> None: def test_set_manager_call_emits_deprecation_warning(self) -> None: # __call__ hands out the raw underlying set, bypassing normalization - # and cache invalidation; removed in 2.0 (#243) + # and cache invalidation; will be removed in 2.0 (#243) sm = SetManager(['dr']) - with pytest.deprecated_call(match="set"): + with pytest.deprecated_call(match="raw underlying set"): elements = sm() self.assertEqual(elements, {'dr'}) @@ -388,6 +388,24 @@ def test_set_manager_remove_missing_member_emits_deprecation_warning(self) -> No sm.remove('dr') self.assertEqual(len(sm), 0) + def test_set_manager_remove_mixed_present_and_missing_in_one_call(self) -> None: + # a single call mixing a present and a missing member must still warn + # (for the missing one) and still invalidate the cache (for the + # present one) -- not short-circuit either behavior + c = Constants() + self.assertIn('hon', c.suffixes_prefixes_titles) # prime the cache + with pytest.deprecated_call(match="discard"): + c.titles.remove('hon', 'nope') + self.assertNotIn('hon', c.suffixes_prefixes_titles) + + def test_set_manager_discard_mixed_present_and_missing_in_one_call(self) -> None: + sm = SetManager(['dr', 'mr']) + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = sm.discard('dr', 'nope') + self.assertIs(result, sm) + self.assertEqual(set(sm), {'mr'}) + def test_pickle_roundtrip_preserves_customizations(self) -> None: """A pickled Constants must restore its customized collections. diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 86df1a0..0ea9878 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -35,7 +35,7 @@ def test_escaped_utf8_bytes(self) -> None: self.m(hn.last, "Böck", hn) def test_bytes_full_name_emits_deprecation_warning(self) -> None: - # bytes input is removed in 2.0 (#245); the caller should decode + # bytes input will be removed in 2.0 (#245); the caller should decode with pytest.deprecated_call(match="decode"): hn = HumanName(b'John Smith') self.m(hn.first, "John", hn)