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
5 changes: 5 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Release Log

- Add ``Constants.copy()``, a detached deep copy that preserves the source instance's current customizations (unlike ``Constants()``, which always starts from library defaults) -- useful as ``CONSTANTS.copy()`` for a private snapshot of the shared config (#260)
- Deprecate passing ``constants=None`` to ``HumanName`` (or assigning ``hn.C = None``): it silently builds a fresh ``Constants()``, discarding any customizations the caller may have expected to carry over from the shared ``CONSTANTS``. Emits ``DeprecationWarning``; will raise ``TypeError`` in 2.0. Use ``constants=Constants()`` for fresh library defaults or ``constants=CONSTANTS.copy()`` for a private snapshot instead (closes #260)
- Deprecate assigning ``Constants.empty_attribute_default`` for removal in 2.0 (#255): once ``None`` support goes, the only legal value left is the default ``''``, so a dial with one position isn't configuration. Emits ``DeprecationWarning``; reading the attribute is unaffected
- Deprecate unknown-key attribute access on ``TupleManager``/``RegexTupleManager`` (``CONSTANTS.regexes.typo``, ``CONSTANTS.capitalization_exceptions.typo``, etc.) for removal in 2.0 (#256): a misspelled or omitted key currently degrades silently (``None``/``EMPTY_REGEX``) with no traceback pointing at the typo. Emits ``DeprecationWarning`` naming the miss and the known keys; will raise ``AttributeError`` in 2.0. ``.get()`` remains available for intentional soft access
- Deprecate ``HumanName`` slice access (``name[1:-3]``) and item assignment (``name['first'] = value``) for removal in 2.0 (#258): field access by position has no real use case, and item assignment duplicates plain attribute assignment. Both emit ``DeprecationWarning``; string-key access (``name['first']``) is unaffected
- Deprecate ``SetManager.add_with_encoding()`` itself for removal in 2.0 (#245), regardless of argument type: use ``add()`` instead (decoding bytes first). Previously only the ``bytes`` path warned; the ``str`` path was silent even though the whole method goes away
- Deprecate loading a legacy-format ``Constants`` pickle (written by nameparser <= 1.2.x, before the 1.3.0 pickle fix) for removal in 2.0 (#279): ``__setstate__``'s migration shim currently skips the stale computed-property key silently. Emits ``DeprecationWarning`` once per call telling users to re-pickle; will raise ``ValueError`` in 2.0
- Fix the ``"Lastname, Firstname"`` comma format not being recognized when the input uses the Arabic comma ``،`` (U+060C, the standard comma in Arabic/Persian/Urdu text) or the fullwidth CJK comma ``,`` (U+FF0C) instead of the ASCII comma: both variants now also split the format and no longer leak into the parsed output (closes #265)

* 1.3.1 - July 11, 2026
Expand Down
12 changes: 5 additions & 7 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,15 @@ Requires Python 3.10+.
True
>>> name.matches("de la vega, dr. juan Q. xavier III")
True
>>> len(name)
5
>>> list(name)
['Dr.', 'Juan', 'Q. Xavier', 'de la Vega', 'III']
>>> name[1:-3]
['Juan', 'Q. Xavier', 'de la Vega']

``name == other`` and ``hash(name)`` are deprecated and will be removed in
2.0; use ``matches()`` for comparison and ``comparison_key()`` for sets,
dicts, and dedup (see `issue #223
<https://github.com/derek73/python-nameparser/issues/223>`_).
<https://github.com/derek73/python-nameparser/issues/223>`_). Slicing a name
by position (``name[1:-3]``) and item assignment (``name['first'] = ...``)
are likewise deprecated and will be removed in 2.0; use the named attributes
instead (see `issue #258
<https://github.com/derek73/python-nameparser/issues/258>`_).

Empty or unparsable input does not raise an error; it produces a name whose
attributes are all empty. Check ``len(name) == 0`` (or ``str(name) == ''``)
Expand Down
118 changes: 113 additions & 5 deletions nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
above; it will raise ``TypeError`` in 2.0 (issue #260).
"""
import copy
import inspect
import re
import sys
import warnings
Expand Down Expand Up @@ -256,7 +257,18 @@ def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None
.. deprecated:: 1.3.0
``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue
#245); decode before adding.

.. deprecated:: 1.4.0
The method itself is removed in 2.0 (see issue #245); use
:py:func:`add` instead, decoding bytes first.
"""
warnings.warn(
"SetManager.add_with_encoding() is deprecated and will be "
"removed in 2.0; use add() instead (decode bytes first). See "
"https://github.com/derek73/python-nameparser/issues/245",
DeprecationWarning,
stacklevel=2,
)
self._add_normalized(s, encoding, stacklevel=3)

def add(self, *strings: str) -> Self:
Expand Down Expand Up @@ -397,10 +409,28 @@ def __init__(
arg = checked
super().__init__(arg, **kwargs)

def _warn_unknown_key(self, attr: str) -> None:
# Deprecated 1.4.0, raises AttributeError in 2.0 (#256): a misspelled
# key otherwise degrades silently with no traceback pointing at the
# typo.
warnings.warn(
f"{attr!r} is not a known key ({', '.join(sorted(self))}); "
"unknown-key attribute access is deprecated and will raise "
"AttributeError in 2.0. Use .get() for intentional soft access. "
"See https://github.com/derek73/python-nameparser/issues/256",
DeprecationWarning,
stacklevel=3,
)

def __getattr__(self, attr: str) -> T | None:
# Otherwise the dict default (None) is mistaken for a real protocol hook.
if _is_dunder(attr):
raise AttributeError(attr)
# Single-underscore introspection probes (IPython/Jupyter's
# _repr_html_, _ipython_canary_method_should_not_exist_, etc.) are
# never config keys either -- no real config key starts with '_'.
if attr not in self and not attr.startswith('_'):
self._warn_unknown_key(attr)
return self.get(attr)

def __setattr__(self, attr: str, value: T) -> None:
Expand Down Expand Up @@ -444,6 +474,8 @@ def __getattr__(self, attr: str) -> re.Pattern[str]:
# then tries to call the returned re.Pattern and raises TypeError.
if _is_dunder(attr):
raise AttributeError(attr)
if attr not in self and not attr.startswith('_'):
self._warn_unknown_key(attr)
return self.get(attr, EMPTY_REGEX)


Expand Down Expand Up @@ -509,6 +541,48 @@ def __set__(self, obj: 'Constants', value: SetManager) -> None:
setattr(obj, self._attr, value)


class _EmptyAttributeDefaultAttribute:
"""Descriptor backing ``Constants.empty_attribute_default``.

.. deprecated:: 1.4.0
Assignment is deprecated (see issue #255): the only legal value
left once ``None`` support goes in 2.0 is the default ``''``, so a
dial with one position isn't configuration.
"""

_attr = '_empty_attribute_default'

def __get__(self, obj: 'Constants | None', objtype: type | None = None) -> str:
# Annotated `str`, not `str | None`, to match the pre-descriptor
# plain-attribute inference: None is documented/supported (see the
# class docstring), but typing it honestly cascades `| None`
# through every public str-typed name accessor (title, first, ...).
# Returning '' rather than `self` on class access (unlike
# _SetManagerAttribute, which returns `self`) is also load-bearing
# for Constants.__repr__'s `getattr(type(self), name)` default
# comparison in _repr_scalar_attrs -- returning `self` there would
# make every Constants() show this attribute as "customized".
if obj is None:
return ''
return getattr(obj, self._attr, '')

def __set__(self, obj: 'Constants', value: str | None) -> None:
if value is not None and not isinstance(value, str):
raise TypeError(
f"empty_attribute_default must be a str or None, got "
f"{type(value).__name__!r}"
)
warnings.warn(
"Assigning Constants.empty_attribute_default is deprecated and "
"will raise TypeError in 2.0; empty attributes will always "
"return ''. See "
"https://github.com/derek73/python-nameparser/issues/255",
DeprecationWarning,
stacklevel=2,
)
setattr(obj, self._attr, value)


class Constants:
"""
An instance of this class hold all of the configuration constants for the parser.
Expand Down Expand Up @@ -615,20 +689,29 @@ class Constants:
does not get mistaken for a suffix split.
"""

empty_attribute_default = ''
empty_attribute_default = _EmptyAttributeDefaultAttribute()
"""
Default return value for empty attributes.

.. deprecated:: 1.4.0
Assignment emits ``DeprecationWarning``; the option is removed in
2.0 (see issue #255) and empty attributes will always return ``''``.

.. doctest::

>>> import warnings
>>> from nameparser.config import CONSTANTS
>>> CONSTANTS.empty_attribute_default = None
>>> with warnings.catch_warnings():
... warnings.simplefilter('ignore', DeprecationWarning)
... CONSTANTS.empty_attribute_default = None
>>> name = HumanName("John Doe")
>>> print(name.title)
None
>>> name.first
'John'
>>> CONSTANTS.empty_attribute_default = ''
>>> with warnings.catch_warnings():
... warnings.simplefilter('ignore', DeprecationWarning)
... CONSTANTS.empty_attribute_default = ''

"""

Expand Down Expand Up @@ -838,7 +921,11 @@ def __setstate__(self, state: Mapping[str, Any]) -> None:
# which silently reverted every collection to its module default on
# unpickling.
self._pst = None
legacy_format = False
for name, value in state.items():
# inspect.getattr_static, not getattr, so descriptors are
# inspected directly rather than triggering their __get__.
descriptor = inspect.getattr_static(type(self), name, None)
# Migration shim: pickles written before this fix (1.3.0 and earlier,
# including 1.2.1) used a dir() sweep for __getstate__, so their state
# carries the read-only ``suffixes_prefixes_titles`` property. Skip any
Expand All @@ -847,9 +934,29 @@ def __setstate__(self, state: Mapping[str, Any]) -> None:
# don't promise to read pre-fix blobs forever — this only smooths
# migration for anyone persisting them, and can be dropped a release
# or two after 1.3.0 once they've re-pickled.
if isinstance(getattr(type(self), name, None), property):
if isinstance(descriptor, property):
legacy_format = True
continue
if isinstance(descriptor, _EmptyAttributeDefaultAttribute):
# Bypass the descriptor's setter: restoring saved state isn't
# a user assignment, so it shouldn't emit #255's deprecation
# warning on every unpickle/copy() of a customized instance.
setattr(self, descriptor._attr, value)
continue
setattr(self, name, value)
if legacy_format:
# Once per __setstate__ call, not once per skipped key (see
# issue #279): the 2.0 removal turns this into a ValueError
# naming the same fix.
warnings.warn(
"Loading a legacy-format Constants pickle (written by "
"nameparser <= 1.2.x, before the 1.3.0 pickle fix) is "
"deprecated and will raise ValueError in 2.0; re-pickle "
"under 1.3/1.4 to migrate. See "
"https://github.com/derek73/python-nameparser/issues/279",
DeprecationWarning,
stacklevel=2,
)
# Verify each descriptor-backed attr was restored. Without this, a missing
# key surfaces later as AttributeError: 'Constants' object has no attribute
# '_prefixes' — the private mangled name, not the public one, making it
Expand All @@ -874,7 +981,8 @@ def __getstate__(self) -> Mapping[str, Any]:
for name, val in self.__dict__.items():
if name.startswith('_'):
public = name[1:]
if isinstance(getattr(type(self), public, None), _SetManagerAttribute):
descriptor = inspect.getattr_static(type(self), public, None)
if isinstance(descriptor, (_SetManagerAttribute, _EmptyAttributeDefaultAttribute)):
state[public] = val
else:
state[name] = val
Expand Down
26 changes: 26 additions & 0 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,38 @@ def __getitem__(self, key: slice) -> list[str]: ...
@overload
def __getitem__(self, key: str) -> str: ...
def __getitem__(self, key: slice | str) -> str | list[str]:
"""
.. deprecated:: 1.4.0
Slice access (``name[1:-3]``) is removed in 2.0 (see issue
#258); field access by position has no real use case.
String-key access (``name['first']``) is unaffected.
"""
if isinstance(key, slice):
warnings.warn(
"Slicing a HumanName by position is deprecated and will be "
"removed in 2.0; access the named attributes instead. See "
"https://github.com/derek73/python-nameparser/issues/258",
DeprecationWarning,
stacklevel=2,
)
return [getattr(self, x) for x in self._members[key]]
else:
return getattr(self, key)

def __setitem__(self, key: str, value: str | list[str] | None) -> None:
"""
.. deprecated:: 1.4.0
Removed in 2.0 (see issue #258); it duplicates plain attribute
assignment. Use ``name.first = value`` instead.
"""
warnings.warn(
"HumanName item assignment is deprecated and will be removed "
"in 2.0; it duplicates plain attribute assignment, use "
"name.first = value instead. See "
"https://github.com/derek73/python-nameparser/issues/258",
DeprecationWarning,
stacklevel=2,
)
if key in self._members:
self._set_list(key, value)
else:
Expand Down
18 changes: 16 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import copy
import warnings
from collections.abc import Iterator

import pytest
Expand Down Expand Up @@ -60,9 +61,22 @@ def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | No
attr: copy.deepcopy(getattr(CONSTANTS, attr))
for attr in _COLLECTION_CONFIG_ATTRS
}
CONSTANTS.empty_attribute_default = request.param
# empty_attribute_default assignment is deprecated (#255); this fixture's
# own systematic exercise of both settings across the whole suite is
# infrastructure, not a test of the deprecation itself, so it's silenced
# here (narrowly, around just the assignment) rather than at every one of
# its ~1500 call sites. Must not wrap `yield` -- that would suppress
# DeprecationWarning for the test body itself, hiding real ones.
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
CONSTANTS.empty_attribute_default = request.param
yield request.param
for attr, value in scalar_snapshot.items():
setattr(CONSTANTS, attr, value)
if attr == "empty_attribute_default":
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
setattr(CONSTANTS, attr, value)
else:
setattr(CONSTANTS, attr, value)
for attr, value in collection_snapshot.items():
setattr(CONSTANTS, attr, value)
8 changes: 7 additions & 1 deletion tests/test_comma_variants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from nameparser import HumanName
from nameparser.config import Constants
from nameparser.config.regexes import REGEXES
Expand Down Expand Up @@ -40,8 +42,12 @@ def test_custom_regexes_without_commas_key_does_not_shatter_name(self) -> None:
# other no-comma input (word tokenizing drops the punctuation),
# yielding a plain first/last pair -- not the inverted "Last, First"
# reading, and definitely not single-character pieces.
# Unknown-key access (here, the deliberately-omitted 'commas') is
# deprecated for removal in 2.0 (#256); this fallback pattern will
# AttributeError-crash the parser instead of degrading silently.
custom = {k: v for k, v in REGEXES.items() if k != 'commas'}
c = Constants(regexes=custom)
hn = HumanName("Smith, John", constants=c)
with pytest.deprecated_call():
hn = HumanName("Smith, John", constants=c)
self.m(hn.first, "Smith", hn)
self.m(hn.last, "John", hn)
Loading
Loading