Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 74 additions & 7 deletions nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -193,38 +208,90 @@ 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).
"""
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
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=stacklevel,
)
s = s.decode(encoding)
normalized = lc(s)
if normalized not in self.elements:
self.elements.add(normalized)
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

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:
Expand Down
24 changes: 22 additions & 2 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class HumanName:
honored). Pass ``None`` for `per-instance config <customize.html>`_.
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
Expand Down Expand Up @@ -135,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':
Expand Down Expand Up @@ -886,9 +890,25 @@ 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):
# 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=stacklevel,
)
self._full_name = value.decode(self.encoding)
else:
self._full_name = value
Expand Down
75 changes: 73 additions & 2 deletions tests/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pickle
import re
import timeit
import warnings
from typing import Any

import pytest
Expand Down Expand Up @@ -332,10 +333,79 @@ 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 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
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; will be removed in 2.0 (#243)
sm = SetManager(['dr'])
with pytest.deprecated_call(match="raw underlying 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_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.

Expand Down Expand Up @@ -622,7 +692,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:
Expand Down
21 changes: 20 additions & 1 deletion tests/test_python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
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)
Expand Down
Loading