Skip to content

Commit 0044073

Browse files
derek73claude
andcommitted
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 <noreply@anthropic.com>
1 parent 4eaeee9 commit 0044073

4 files changed

Lines changed: 57 additions & 19 deletions

File tree

nameparser/config/__init__.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -208,16 +208,11 @@ def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[overrid
208208

209209
__rxor__ = __xor__
210210

211-
def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None:
212-
"""
213-
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
214-
explicit `encoding` parameter to specify the encoding of binary strings that
215-
are not DEFAULT_ENCODING (UTF-8).
216-
217-
.. deprecated:: 1.3.0
218-
``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue
219-
#245); decode before adding.
220-
"""
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.
221216
stdin_encoding = None
222217
if sys.stdin:
223218
stdin_encoding = sys.stdin.encoding
@@ -229,7 +224,7 @@ def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None
229224
"first, e.g. value.decode('utf-8'). See "
230225
"https://github.com/derek73/python-nameparser/issues/245",
231226
DeprecationWarning,
232-
stacklevel=2,
227+
stacklevel=stacklevel,
233228
)
234229
s = s.decode(encoding)
235230
normalized = lc(s)
@@ -238,13 +233,29 @@ def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None
238233
if self._on_change:
239234
self._on_change()
240235

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+
241248
def add(self, *strings: str) -> Self:
242249
"""
243250
Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set.
244251
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.
245256
"""
246257
for s in strings:
247-
self.add_with_encoding(s)
258+
self._add_normalized(s, None, stacklevel=3)
248259

249260
return self
250261

nameparser/parser.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,10 @@ def __init__(
137137
self.nickname = nickname
138138
self.maiden = maiden
139139
else:
140-
# full_name setter triggers the parse
141-
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)
142144

143145
@staticmethod
144146
def _validate_constants(constants: 'Constants | None') -> 'Constants':
@@ -888,6 +890,13 @@ def full_name(self) -> str:
888890

889891
@full_name.setter
890892
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.
891900
self.original = value
892901

893902
if isinstance(value, bytes):
@@ -898,7 +907,7 @@ def full_name(self, value: str | bytes) -> None:
898907
"value.decode('utf-8'). See "
899908
"https://github.com/derek73/python-nameparser/issues/245",
900909
DeprecationWarning,
901-
stacklevel=2,
910+
stacklevel=stacklevel,
902911
)
903912
self._full_name = value.decode(self.encoding)
904913
else:

tests/test_constants.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ def test_add_constant_with_explicit_encoding(self) -> None:
340340
self.assertIn('béck', c.titles)
341341

342342
def test_set_manager_add_bytes_emits_deprecation_warning(self) -> None:
343-
# bytes elements are removed in 2.0 (#245); the caller should decode
343+
# bytes elements will be removed in 2.0 (#245); the caller should decode
344344
sm = SetManager(['dr'])
345345
with pytest.deprecated_call(match="decode"):
346346
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:
355355

356356
def test_set_manager_call_emits_deprecation_warning(self) -> None:
357357
# __call__ hands out the raw underlying set, bypassing normalization
358-
# and cache invalidation; removed in 2.0 (#243)
358+
# and cache invalidation; will be removed in 2.0 (#243)
359359
sm = SetManager(['dr'])
360-
with pytest.deprecated_call(match="set"):
360+
with pytest.deprecated_call(match="raw underlying set"):
361361
elements = sm()
362362
self.assertEqual(elements, {'dr'})
363363

@@ -388,6 +388,24 @@ def test_set_manager_remove_missing_member_emits_deprecation_warning(self) -> No
388388
sm.remove('dr')
389389
self.assertEqual(len(sm), 0)
390390

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+
391409
def test_pickle_roundtrip_preserves_customizations(self) -> None:
392410
"""A pickled Constants must restore its customized collections.
393411

tests/test_python_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def test_escaped_utf8_bytes(self) -> None:
3535
self.m(hn.last, "Böck", hn)
3636

3737
def test_bytes_full_name_emits_deprecation_warning(self) -> None:
38-
# bytes input is removed in 2.0 (#245); the caller should decode
38+
# bytes input will be removed in 2.0 (#245); the caller should decode
3939
with pytest.deprecated_call(match="decode"):
4040
hn = HumanName(b'John Smith')
4141
self.m(hn.first, "John", hn)

0 commit comments

Comments
 (0)