Skip to content

Commit 7d8895d

Browse files
committed
Merge branch 'master' into release/v1.3.0
2 parents 749bf66 + 9b3dadf commit 7d8895d

6 files changed

Lines changed: 193 additions & 13 deletions

File tree

docs/customize.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Editable attributes of nameparser.config.CONSTANTS
5353
* :py:data:`~nameparser.config.CAPITALIZATION_EXCEPTIONS` - Dictionary of pieces that do not capitalize the first letter, e.g. "Ph.D".
5454
* :py:data:`~nameparser.config.REGEXES` - Regular expressions used to find words, initials, nicknames, etc.
5555

56-
Each set-valued constant comes with :py:func:`~nameparser.config.SetManager.add` and :py:func:`~nameparser.config.SetManager.remove` methods for tuning
56+
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
5757
the constants for your project. These methods automatically lower case and
5858
remove punctuation to normalize them for comparison. The two dict-valued
5959
constants (``CAPITALIZATION_EXCEPTIONS`` and ``REGEXES``) are edited with

docs/release_log.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Release Log
55
**Breaking Changes & Deprecations**
66

77
- 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)
8+
- 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
9+
- 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
10+
- 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
811
- 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)
912
- 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
1013
- Remove ``__ne__``; Python 3 derives ``!=`` from ``__eq__`` automatically

nameparser/config/__init__.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"""
2929
import re
3030
import sys
31+
import warnings
3132
from collections.abc import Callable, Iterable, Iterator, Mapping, Set
3233
from typing import Any, TypeVar, overload
3334

@@ -137,6 +138,20 @@ def __init__(self, elements: Iterable[str]) -> None:
137138
self._on_change = None
138139

139140
def __call__(self) -> Set[str]:
141+
"""
142+
.. deprecated:: 1.3.0
143+
Removed in 2.0 (see issue #243). Returns the raw underlying set,
144+
so mutating it bypasses normalization and cache invalidation;
145+
iterate the manager or copy with ``set(manager)`` instead.
146+
"""
147+
warnings.warn(
148+
"Calling a SetManager to get the raw underlying set is "
149+
"deprecated and will be removed in 2.0; iterate the manager or "
150+
"copy it with set(manager) instead. See "
151+
"https://github.com/derek73/python-nameparser/issues/243",
152+
DeprecationWarning,
153+
stacklevel=2,
154+
)
140155
return self.elements
141156

142157
def __repr__(self) -> str:
@@ -193,38 +208,90 @@ def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[overrid
193208

194209
__rxor__ = __xor__
195210

196-
def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None:
197-
"""
198-
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
199-
explicit `encoding` parameter to specify the encoding of binary strings that
200-
are not DEFAULT_ENCODING (UTF-8).
201-
"""
211+
def _add_normalized(self, s: str | bytes, encoding: str | None, *, stacklevel: int) -> None:
212+
# Shared by add() and add_with_encoding() so each can call it
213+
# directly with a stacklevel that attributes the warning to *its own*
214+
# caller -- add() delegating to add_with_encoding() would otherwise
215+
# add a frame and misattribute the warning to this module.
202216
stdin_encoding = None
203217
if sys.stdin:
204218
stdin_encoding = sys.stdin.encoding
205219
encoding = encoding or stdin_encoding or DEFAULT_ENCODING
206220
if isinstance(s, bytes):
221+
warnings.warn(
222+
"Passing bytes to SetManager.add()/add_with_encoding() is "
223+
"deprecated and will raise TypeError in 2.0; decode it "
224+
"first, e.g. value.decode('utf-8'). See "
225+
"https://github.com/derek73/python-nameparser/issues/245",
226+
DeprecationWarning,
227+
stacklevel=stacklevel,
228+
)
207229
s = s.decode(encoding)
208230
normalized = lc(s)
209231
if normalized not in self.elements:
210232
self.elements.add(normalized)
211233
if self._on_change:
212234
self._on_change()
213235

236+
def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None:
237+
"""
238+
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
239+
explicit `encoding` parameter to specify the encoding of binary strings that
240+
are not DEFAULT_ENCODING (UTF-8).
241+
242+
.. deprecated:: 1.3.0
243+
``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue
244+
#245); decode before adding.
245+
"""
246+
self._add_normalized(s, encoding, stacklevel=3)
247+
214248
def add(self, *strings: str) -> Self:
215249
"""
216250
Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set.
217251
Returns ``self`` for chaining.
252+
253+
.. deprecated:: 1.3.0
254+
``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue
255+
#245); decode before adding.
218256
"""
219257
for s in strings:
220-
self.add_with_encoding(s)
258+
self._add_normalized(s, None, stacklevel=3)
221259

222260
return self
223261

224262
def remove(self, *strings: str) -> Self:
225263
"""
226264
Remove the lower case and no-period version of the string arguments from the set.
227265
Returns ``self`` for chaining.
266+
267+
.. deprecated:: 1.3.0
268+
Removing a *missing* member currently does nothing but will
269+
raise ``KeyError`` in 2.0, matching ``set.remove`` (see issue
270+
#243); use :py:func:`discard` to ignore missing members.
271+
"""
272+
changed = False
273+
for s in strings:
274+
if (lower := lc(s)) in self.elements:
275+
self.elements.remove(lower)
276+
changed = True
277+
else:
278+
warnings.warn(
279+
"SetManager.remove() of a missing member currently does "
280+
"nothing, but will raise KeyError in 2.0; use discard() "
281+
"to ignore missing members. See "
282+
"https://github.com/derek73/python-nameparser/issues/243",
283+
DeprecationWarning,
284+
stacklevel=2,
285+
)
286+
if changed and self._on_change:
287+
self._on_change()
288+
return self
289+
290+
def discard(self, *strings: str) -> Self:
291+
"""
292+
Remove the lower case and no-period version of the string arguments
293+
from the set if present; missing members are ignored, like
294+
``set.discard``. Returns ``self`` for chaining.
228295
"""
229296
changed = False
230297
for s in strings:

nameparser/parser.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class HumanName:
5454
honored). Pass ``None`` for `per-instance config <customize.html>`_.
5555
Anything else raises ``TypeError``.
5656
:param str encoding: string representing the encoding of your input
57+
(deprecated with ``bytes`` input, removal in 2.0 — decode before
58+
passing; see issue #245)
5759
:param str string_format: python string formatting
5860
:param str initials_format: python initials string formatting
5961
:param str initials_delimter: string delimiter for initials
@@ -135,8 +137,10 @@ def __init__(
135137
self.nickname = nickname
136138
self.maiden = maiden
137139
else:
138-
# full_name setter triggers the parse
139-
self.full_name = full_name
140+
# calls _apply_full_name directly (not the setter) so the
141+
# deprecation warning below attributes to this constructor's
142+
# caller rather than to the setter
143+
self._apply_full_name(full_name, stacklevel=3)
140144

141145
@staticmethod
142146
def _validate_constants(constants: 'Constants | None') -> 'Constants':
@@ -886,9 +890,25 @@ def full_name(self) -> str:
886890

887891
@full_name.setter
888892
def full_name(self, value: str | bytes) -> None:
893+
self._apply_full_name(value, stacklevel=3)
894+
895+
def _apply_full_name(self, value: str | bytes, *, stacklevel: int) -> None:
896+
# Shared by the setter and the constructor so each can call it
897+
# directly with a stacklevel that attributes the warning to *its own*
898+
# caller -- the constructor going through the setter would otherwise
899+
# add a frame and misattribute the warning to this module.
889900
self.original = value
890901

891902
if isinstance(value, bytes):
903+
# deprecated 1.3.0, raises TypeError in 2.0 (#245)
904+
warnings.warn(
905+
"Passing bytes to HumanName is deprecated and will raise "
906+
"TypeError in 2.0; decode it first, e.g. "
907+
"value.decode('utf-8'). See "
908+
"https://github.com/derek73/python-nameparser/issues/245",
909+
DeprecationWarning,
910+
stacklevel=stacklevel,
911+
)
892912
self._full_name = value.decode(self.encoding)
893913
else:
894914
self._full_name = value

tests/test_constants.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pickle
33
import re
44
import timeit
5+
import warnings
56
from typing import Any
67

78
import pytest
@@ -332,10 +333,79 @@ def test_none_empty_attribute_string_formatting(self) -> None:
332333
self.assertEqual('', str(hn), hn)
333334

334335
def test_add_constant_with_explicit_encoding(self) -> None:
336+
# bytes input is deprecated (#245), still supported until 2.0
335337
c = Constants()
336-
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
338+
with pytest.deprecated_call():
339+
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
337340
self.assertIn('béck', c.titles)
338341

342+
def test_set_manager_add_bytes_emits_deprecation_warning(self) -> None:
343+
# bytes elements will be removed in 2.0 (#245); the caller should decode
344+
sm = SetManager(['dr'])
345+
with pytest.deprecated_call(match="decode"):
346+
sm.add(b'esq') # type: ignore[arg-type] # deliberately deprecated input
347+
self.assertIn('esq', sm)
348+
349+
def test_set_manager_add_str_does_not_warn(self) -> None:
350+
sm = SetManager(['dr'])
351+
with warnings.catch_warnings():
352+
warnings.simplefilter("error")
353+
sm.add('esq')
354+
self.assertIn('esq', sm)
355+
356+
def test_set_manager_call_emits_deprecation_warning(self) -> None:
357+
# __call__ hands out the raw underlying set, bypassing normalization
358+
# and cache invalidation; will be removed in 2.0 (#243)
359+
sm = SetManager(['dr'])
360+
with pytest.deprecated_call(match="raw underlying set"):
361+
elements = sm()
362+
self.assertEqual(elements, {'dr'})
363+
364+
def test_set_manager_discard_ignores_missing_without_warning(self) -> None:
365+
sm = SetManager(['dr', 'mr'])
366+
with warnings.catch_warnings():
367+
warnings.simplefilter("error")
368+
result = sm.discard('nope').discard('Dr.') # normalizes like remove()
369+
self.assertIs(result, sm)
370+
self.assertEqual(set(sm), {'mr'})
371+
372+
def test_set_manager_discard_invalidates_cached_union(self) -> None:
373+
c = Constants()
374+
self.assertIn('hon', c.suffixes_prefixes_titles) # prime the cache
375+
c.titles.discard('hon')
376+
self.assertNotIn('hon', c.suffixes_prefixes_titles)
377+
378+
def test_set_manager_remove_missing_member_emits_deprecation_warning(self) -> None:
379+
# ignore-missing remove() becomes KeyError in 2.0 (#243); discard()
380+
# is the intentional ignore-missing spelling
381+
sm = SetManager(['dr'])
382+
with pytest.deprecated_call(match="discard"):
383+
sm.remove('nope')
384+
self.assertEqual(set(sm), {'dr'})
385+
# removing a present member stays silent
386+
with warnings.catch_warnings():
387+
warnings.simplefilter("error")
388+
sm.remove('dr')
389+
self.assertEqual(len(sm), 0)
390+
391+
def test_set_manager_remove_mixed_present_and_missing_in_one_call(self) -> None:
392+
# a single call mixing a present and a missing member must still warn
393+
# (for the missing one) and still invalidate the cache (for the
394+
# present one) -- not short-circuit either behavior
395+
c = Constants()
396+
self.assertIn('hon', c.suffixes_prefixes_titles) # prime the cache
397+
with pytest.deprecated_call(match="discard"):
398+
c.titles.remove('hon', 'nope')
399+
self.assertNotIn('hon', c.suffixes_prefixes_titles)
400+
401+
def test_set_manager_discard_mixed_present_and_missing_in_one_call(self) -> None:
402+
sm = SetManager(['dr', 'mr'])
403+
with warnings.catch_warnings():
404+
warnings.simplefilter("error")
405+
result = sm.discard('dr', 'nope')
406+
self.assertIs(result, sm)
407+
self.assertEqual(set(sm), {'mr'})
408+
339409
def test_pickle_roundtrip_preserves_customizations(self) -> None:
340410
"""A pickled Constants must restore its customized collections.
341411
@@ -622,7 +692,8 @@ def test_suffixes_prefixes_titles_reflects_add_with_encoding(self) -> None:
622692
"""add_with_encoding must invalidate the cache like add()/remove() do."""
623693
c = Constants()
624694
_ = c.suffixes_prefixes_titles # prime the cache
625-
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
695+
with pytest.deprecated_call(): # bytes input deprecated (#245)
696+
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
626697
self.assertIn('béck', c.suffixes_prefixes_titles)
627698

628699
def test_suffixes_prefixes_titles_reflects_replaced_manager(self) -> None:

tests/test_python_api.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,29 @@ def test_string_output(self) -> None:
2828
self.m(str(hn), "Jüan de la Véña", hn)
2929

3030
def test_escaped_utf8_bytes(self) -> None:
31-
hn = HumanName(b'B\xc3\xb6ck, Gerald')
31+
# bytes input is deprecated (#245), still supported until 2.0
32+
with pytest.deprecated_call():
33+
hn = HumanName(b'B\xc3\xb6ck, Gerald')
3234
self.m(hn.first, "Gerald", hn)
3335
self.m(hn.last, "Böck", hn)
3436

37+
def test_bytes_full_name_emits_deprecation_warning(self) -> None:
38+
# bytes input will be removed in 2.0 (#245); the caller should decode
39+
with pytest.deprecated_call(match="decode"):
40+
hn = HumanName(b'John Smith')
41+
self.m(hn.first, "John", hn)
42+
hn2 = HumanName("Jane Doe")
43+
with pytest.deprecated_call(match="decode"):
44+
hn2.full_name = b'John Smith'
45+
self.m(hn2.first, "John", hn2)
46+
47+
def test_str_full_name_does_not_warn(self) -> None:
48+
with warnings.catch_warnings():
49+
warnings.simplefilter("error")
50+
hn = HumanName("John Smith")
51+
hn.full_name = "Jane Doe"
52+
self.m(hn.first, "Jane", hn)
53+
3554
def test_len(self) -> None:
3655
hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
3756
self.m(len(hn), 5, hn)

0 commit comments

Comments
 (0)