diff --git a/docs/release_log.rst b/docs/release_log.rst index 08e5e88..cf82cf2 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -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 diff --git a/docs/usage.rst b/docs/usage.rst index f514929..8c9ae36 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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 -`_). +`_). 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 +`_). 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) == ''``) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 5d61861..6f1e798 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -40,6 +40,7 @@ above; it will raise ``TypeError`` in 2.0 (issue #260). """ import copy +import inspect import re import sys import warnings @@ -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: @@ -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: @@ -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) @@ -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. @@ -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 = '' """ @@ -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 @@ -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 @@ -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 diff --git a/nameparser/parser.py b/nameparser/parser.py index 8aaa1e9..1e1e786 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index 06b5654..79dbfd9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import copy +import warnings from collections.abc import Iterator import pytest @@ -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) diff --git a/tests/test_comma_variants.py b/tests/test_comma_variants.py index 7e1a74e..ca53581 100644 --- a/tests/test_comma_variants.py +++ b/tests/test_comma_variants.py @@ -1,3 +1,5 @@ +import pytest + from nameparser import HumanName from nameparser.config import Constants from nameparser.config.regexes import REGEXES @@ -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) diff --git a/tests/test_constants.py b/tests/test_constants.py index 187024a..54c2650 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -301,15 +301,41 @@ def test_clear_removes_all_entries(self) -> None: self.m(hn.middle, "Hon", hn) self.m(hn.last, "Solo", hn) + def test_empty_attribute_default_assignment_emits_deprecation_warning(self) -> None: + # assigning empty_attribute_default is deprecated for removal in 2.0 + # (#255); empty attributes will always return '' once removed + c = Constants() + with pytest.deprecated_call(match="255"): + c.empty_attribute_default = None # type: ignore[assignment] + self.assertIsNone(c.empty_attribute_default) + + def test_empty_attribute_default_rejects_non_str_non_none(self) -> None: + # A cheap early check on the invariant 2.0 will fully enforce (only + # '' will be legal): non-str/non-None values fail loudly here + # instead of surfacing later as a confusing failure deep in some + # unrelated HumanName string property. + c = Constants() + with pytest.raises(TypeError, match="str or None"): + c.empty_attribute_default = 42 # type: ignore[assignment] + self.assertEqual(c.empty_attribute_default, '') + + def test_empty_attribute_default_read_does_not_warn(self) -> None: + c = Constants() + with warnings.catch_warnings(): + warnings.simplefilter("error") + self.assertEqual(c.empty_attribute_default, '') + def test_empty_attribute_default(self) -> None: from nameparser.config import CONSTANTS # empty_attribute_default has no explicit annotation (mypy infers str # from the '' default), but None is documented/supported here -- see # the doctest on the attribute's docstring in config/__init__.py. # Not widened to str | None like string_format/suffix_delimiter - # because it cascades into ~8 public str-typed properties (title, - # first, middle, last, suffix, nickname, initials()). - CONSTANTS.empty_attribute_default = None # type: ignore[assignment] + # because it cascades into every public str-typed name accessor + # (title, first, middle, last, suffix, nickname, maiden, surnames, + # given_names, last_base, last_prefixes, initials()). + with pytest.deprecated_call(): + CONSTANTS.empty_attribute_default = None # type: ignore[assignment] hn = HumanName("") self.m(hn.title, None, hn) self.m(hn.first, None, hn) @@ -320,7 +346,8 @@ def test_empty_attribute_default(self) -> None: def test_empty_attribute_on_instance(self) -> None: hn = HumanName("", Constants()) - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above + with pytest.deprecated_call(): + hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above self.m(hn.title, None, hn) self.m(hn.first, None, hn) self.m(hn.middle, None, hn) @@ -330,7 +357,8 @@ def test_empty_attribute_on_instance(self) -> None: def test_none_empty_attribute_string_formatting(self) -> None: hn = HumanName("", Constants()) - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above + with pytest.deprecated_call(): + hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above self.assertEqual('', str(hn), hn) def test_add_constant_with_explicit_encoding(self) -> None: @@ -347,6 +375,33 @@ def test_set_manager_add_bytes_emits_deprecation_warning(self) -> None: sm.add(b'esq') # type: ignore[arg-type] # deliberately deprecated input self.assertIn('esq', sm) + def test_add_with_encoding_str_emits_deprecation_warning(self) -> None: + # add_with_encoding() itself is deprecated in 1.4 for removal in 2.0 + # (#263/#245); the str path is otherwise silent, so this method's + # own removal is unwarned without this + sm = SetManager(['dr']) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sm.add_with_encoding('esq') + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + # Exactly one -- the str path must not also trip the bytes-decode + # warning (that one's for the *other* argument type). + self.assertEqual(len(deprecations), 1) + self.assertIn('add()', str(deprecations[0].message)) + self.assertIn('esq', sm) + + def test_add_with_encoding_bytes_emits_two_distinct_warnings(self) -> None: + # the bytes-decode warning (#245, shipped 1.3.0) and the + # add_with_encoding()-removal warning (#263) are independent + sm = SetManager(['dr']) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sm.add_with_encoding(b'esq') + messages = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)] + self.assertEqual(len(messages), 2) + self.assertTrue(any('decode' in m for m in messages)) + self.assertTrue(any('use add() instead' in m for m in messages)) + def test_set_manager_add_str_does_not_warn(self) -> None: sm = SetManager(['dr']) with warnings.catch_warnings(): @@ -438,10 +493,14 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None: """An instance-level scalar override must survive a pickle round-trip.""" c = Constants() - c.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above + with pytest.deprecated_call(): + c.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above # Safe: round-tripping a Constants the test just built, not untrusted data. - restored = pickle.loads(pickle.dumps(c)) + # Restoring a pickled state is not itself a #255-deprecated assignment. + with warnings.catch_warnings(): + warnings.simplefilter("error") + restored = pickle.loads(pickle.dumps(c)) self.assertEqual(restored.empty_attribute_default, None) @@ -466,11 +525,65 @@ def test_unpickle_legacy_state_with_property_key(self) -> None: self.assertIn('suffixes_prefixes_titles', legacy_state) restored = Constants.__new__(Constants) - restored.__setstate__(legacy_state) + with pytest.deprecated_call(): # legacy-format load deprecated (#279) + restored.__setstate__(legacy_state) # The real customization is recovered and the property key is ignored. self.assertIn('legacytitle', restored.titles) + def test_unpickle_legacy_state_emits_deprecation_warning_once(self) -> None: + # legacy-format unpickling is deprecated for removal in 2.0 (#279): + # the migration shim will stop skipping the computed-property key and + # raise ValueError instead; warn once per __setstate__ call (not once + # per skipped key) telling users to re-pickle + c = Constants() + legacy_state = { + name: getattr(c, name) for name in dir(c) if not name.startswith('_') + } + self.assertIn('suffixes_prefixes_titles', legacy_state) + + restored = Constants.__new__(Constants) + with pytest.deprecated_call(match="re-pickle") as record: + restored.__setstate__(legacy_state) + deprecations = [w for w in record.list if issubclass(w.category, DeprecationWarning)] + self.assertEqual(len(deprecations), 1) + + def test_unpickle_legacy_state_with_two_stale_property_keys_warns_once(self) -> None: + # Pins "once per __setstate__ call, not once per skipped key": with + # only one computed property (suffixes_prefixes_titles) on the real + # Constants class, the test above can't distinguish the two + # semantics. A second read-only property on a throwaway subclass + # forces two keys through the skip branch in one __setstate__ call. + class ConstantsWithExtraProperty(Constants): + @property + def another_computed_property(self) -> str: + return 'computed' + + c = ConstantsWithExtraProperty() + legacy_state = { + name: getattr(c, name) for name in dir(c) if not name.startswith('_') + } + self.assertIn('suffixes_prefixes_titles', legacy_state) + self.assertIn('another_computed_property', legacy_state) + + restored = ConstantsWithExtraProperty.__new__(ConstantsWithExtraProperty) + with pytest.deprecated_call(match="re-pickle") as record: + restored.__setstate__(legacy_state) + deprecations = [w for w in record.list if issubclass(w.category, DeprecationWarning)] + self.assertEqual(len(deprecations), 1) + + def test_setstate_without_legacy_keys_does_not_warn(self) -> None: + c = Constants() + c.titles.add('legacytitle') + state = c.__getstate__() + self.assertNotIn('suffixes_prefixes_titles', state) + + restored = Constants.__new__(Constants) + with warnings.catch_warnings(): + warnings.simplefilter("error") + restored.__setstate__(state) + self.assertIn('legacytitle', restored.titles) + def test_pickle_roundtrip_preserves_regex_manager_subclass(self) -> None: """regexes must round-trip as a RegexTupleManager, not a plain TupleManager. @@ -485,7 +598,8 @@ def test_pickle_roundtrip_preserves_regex_manager_subclass(self) -> None: restored = pickle.loads(pickle.dumps(c)) self.assertEqual(type(restored.regexes), RegexTupleManager) - self.assertEqual(restored.regexes.does_not_exist, EMPTY_REGEX) + with pytest.deprecated_call(): # unknown-key access deprecated (#256) + self.assertEqual(restored.regexes.does_not_exist, EMPTY_REGEX) def test_regexes_deepcopy_roundtrip(self) -> None: """copy.deepcopy of a RegexTupleManager must round-trip. @@ -500,8 +614,10 @@ def test_regexes_deepcopy_roundtrip(self) -> None: self.assertEqual(type(dup), RegexTupleManager) self.assertEqual(dict(dup), dict(c.regexes)) - # The EMPTY_REGEX default still applies to genuinely unknown keys. - self.assertEqual(dup.does_not_exist, EMPTY_REGEX) + # The EMPTY_REGEX default still applies to genuinely unknown keys, + # but accessing one is now deprecated (#256). + with pytest.deprecated_call(): + self.assertEqual(dup.does_not_exist, EMPTY_REGEX) def test_nickname_delimiters_deepcopy_roundtrip(self) -> None: """copy.deepcopy of nickname_delimiters must round-trip. @@ -517,8 +633,10 @@ def test_nickname_delimiters_deepcopy_roundtrip(self) -> None: self.assertEqual(type(dup), TupleManager) self.assertEqual(dict(dup), dict(c.nickname_delimiters)) - # Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are None. - self.assertIsNone(dup.does_not_exist) + # Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are + # None, but accessing one is now deprecated (#256). + with pytest.deprecated_call(): + self.assertIsNone(dup.does_not_exist) self.assertIsNotNone(dup.curly_braces) def test_maiden_delimiters_deepcopy_roundtrip(self) -> None: @@ -529,7 +647,8 @@ def test_maiden_delimiters_deepcopy_roundtrip(self) -> None: self.assertEqual(type(dup), TupleManager) self.assertEqual(dict(dup), {}) - self.assertIsNone(dup.does_not_exist) + with pytest.deprecated_call(): # unknown-key access deprecated (#256) + self.assertIsNone(dup.does_not_exist) def test_nickname_delimiters_default_builtins_resolve_live(self) -> None: # The three built-ins are stored as the *name* of a regexes entry @@ -585,8 +704,10 @@ def test_regextuplemanager_ignores_dunder_lookups(self) -> None: sentinel = object() self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel) - # A normal (non-dunder) unknown key still yields the EMPTY_REGEX default. - self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX) + # A normal (non-dunder) unknown key still yields the EMPTY_REGEX + # default, but accessing one is now deprecated (#256). + with pytest.deprecated_call(): + self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX) def test_tuplemanager_ignores_dunder_lookups(self) -> None: """Base TupleManager must report unknown dunder names as absent too. @@ -602,8 +723,65 @@ def test_tuplemanager_ignores_dunder_lookups(self) -> None: self.assertEqual(type(tm), TupleManager) self.assertFalse(hasattr(tm, '__deepcopy__')) self.assertEqual(getattr(tm, '__wrapped__', sentinel), sentinel) - # A normal (non-dunder) unknown key still returns the None default. - self.assertEqual(tm.unknown_key, None) + # A normal (non-dunder) unknown key still returns the None default, + # but accessing one is now deprecated (#256). + with pytest.deprecated_call(): + self.assertEqual(tm.unknown_key, None) + + def test_sunder_probe_does_not_emit_deprecation_warning(self) -> None: + # Single-underscore introspection probes (IPython/Jupyter's + # _repr_html_, _ipython_canary_method_should_not_exist_, etc.) are + # never config keys, just like dunders -- warning on them would + # misleadingly flag a typo merely for e.g. displaying CONSTANTS.regexes + # in a notebook. No real config key starts with '_'. + c = Constants() + with warnings.catch_warnings(): + warnings.simplefilter("error") + self.assertEqual(c.regexes._repr_html_, EMPTY_REGEX) + self.assertIsNone(c.capitalization_exceptions._ipython_canary_method_should_not_exist_) + + def test_tuplemanager_unknown_key_emits_deprecation_warning(self) -> None: + # unknown-key attribute access is deprecated for removal in 2.0 + # (#256); will become AttributeError naming the known keys + c = Constants() + tm = c.capitalization_exceptions + with pytest.deprecated_call(match="phd_typo") as record: + result = tm.phd_typo + self.assertIsNone(result) + message = str(record.list[0].message) + for key in tm.keys(): + self.assertIn(key, message) + + def test_tuplemanager_known_key_does_not_warn(self) -> None: + c = Constants() + with warnings.catch_warnings(): + warnings.simplefilter("error") + list(c.capitalization_exceptions.keys()) # sanity: has entries + first_key = next(iter(c.capitalization_exceptions)) + getattr(c.capitalization_exceptions, first_key) + + def test_regextuplemanager_unknown_key_emits_deprecation_warning(self) -> None: + c = Constants() + with pytest.deprecated_call(match="parenthesys") as record: + result = c.regexes.parenthesys + self.assertEqual(result, EMPTY_REGEX) + message = str(record.list[0].message) + self.assertIn('mac', message) # a known regexes key + + def test_regextuplemanager_known_key_does_not_warn(self) -> None: + c = Constants() + with warnings.catch_warnings(): + warnings.simplefilter("error") + c.regexes.mac + + def test_dunder_probe_does_not_emit_deprecation_warning(self) -> None: + # dunder protocol probes must stay silent -- only real config typos warn + c = Constants() + sentinel = object() + with warnings.catch_warnings(): + warnings.simplefilter("error") + self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel) + self.assertEqual(getattr(c.capitalization_exceptions, '__deepcopy__', sentinel), sentinel) def test_suffixes_prefixes_titles_reflects_add_title(self) -> None: @@ -997,6 +1175,20 @@ class CustomConstants(Constants): dup = c.copy() self.assertTrue(isinstance(dup, CustomConstants)) + def test_copy_preserves_empty_attribute_default_without_warning(self) -> None: + # copy() round-trips through __getstate__/__setstate__ like pickle + # does; restoring saved state isn't a user assignment, so it must not + # emit #255's deprecation warning (the __setstate__ bypass exists + # specifically for this call path, not just pickle.loads). + c = Constants() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + c.empty_attribute_default = None # type: ignore[assignment] + with warnings.catch_warnings(): + warnings.simplefilter("error") + dup = c.copy() + self.assertIsNone(dup.empty_attribute_default) + def test_copy_snapshots_current_customizations(self) -> None: # Unlike Constants(), which always starts from library defaults, # .copy() preserves whatever customizations the original already has. diff --git a/tests/test_initials.py b/tests/test_initials.py index dabebe5..c50879f 100644 --- a/tests/test_initials.py +++ b/tests/test_initials.py @@ -1,3 +1,5 @@ +import pytest + from nameparser import HumanName from nameparser.config import Constants @@ -30,9 +32,11 @@ def test_initials_empty_part_with_none_default_not_literal_none(self) -> None: # from the '' default), but None is documented/supported here -- see # the doctest on the attribute's docstring in config/__init__.py. Not # widened to str | None like string_format/suffix_delimiter because - # it cascades into ~8 public str-typed properties (title, first, - # middle, last, suffix, nickname, initials()). - hn.C.empty_attribute_default = None # type: ignore[assignment] + # it cascades into every public str-typed name accessor (title, + # first, middle, last, suffix, nickname, maiden, surnames, + # given_names, last_base, last_prefixes, initials()). + with pytest.deprecated_call(): + hn.C.empty_attribute_default = None # type: ignore[assignment] self.assertEqual(hn.initials(), "J. D.") self.assertTrue("None" not in hn.initials()) @@ -41,7 +45,8 @@ def test_initials_all_empty_returns_empty_attribute_default(self) -> None: # empty_attribute_default (here None), matching the first/last accessors, # rather than rendering the literal "None None None". hn = HumanName("", constants=Constants()) - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test above + with pytest.deprecated_call(): + hn.C.empty_attribute_default = None # type: ignore[assignment] # see test above self.assertEqual(hn.initials(), None) def test_initials_middle_name_all_prefixes(self) -> None: diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index 07c0083..423f5b1 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -223,9 +223,10 @@ def test_maiden_appears_in_as_dict_when_populated(self) -> None: self.assertEqual(hn.as_dict(False)['maiden'], "Johnson") def test_maiden_appears_in_slice(self) -> None: + # list(hn), not the deprecated hn[:] slice (#258) hn = HumanName("Jenny Baker") hn.maiden = "Johnson" - self.assertIn("Johnson", hn[:]) + self.assertIn("Johnson", list(hn)) def test_maiden_via_constructor_kwarg(self) -> None: hn = HumanName(first="Jenny", last="Baker", maiden="Johnson") diff --git a/tests/test_output_format.py b/tests/test_output_format.py index b49f21f..d7c4cfc 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -1,3 +1,5 @@ +import pytest + from nameparser import HumanName from nameparser.config import Constants @@ -105,14 +107,16 @@ def test_name_containing_none_substring_with_none_empty_attribute_default(self) # scrubbed the literal string 'None' from the formatted output, # corrupting real name text containing that substring. hn = HumanName("Nonez Smith", Constants()) - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default + with pytest.deprecated_call(): + hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default self.assertEqual(str(hn), "Nonez Smith") def test_name_none_as_literal_name_with_none_empty_attribute_default(self) -> None: # Companion to the #254 regression: a name piece that is exactly # 'None' must survive formatting in None-mode. hn = HumanName("None Smith", Constants()) - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default + with pytest.deprecated_call(): + hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default self.assertEqual(str(hn), "None Smith") def test_empty_field_drops_surrounding_whitespace(self) -> None: diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 61e53c3..f0be687 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -461,8 +461,21 @@ def test_repr_blank_name(self) -> None: def test_slice(self) -> None: hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(list(hn), ['Dr.', 'John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC'], hn) - self.m(hn[1:], ['John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC', hn.C.empty_attribute_default, hn.C.empty_attribute_default], hn) - self.m(hn[1:-3], ['John', 'P.', 'Doe-Ray'], hn) + with pytest.deprecated_call(match="Slic"): + sliced = hn[1:] + self.m(sliced, ['John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC', hn.C.empty_attribute_default, hn.C.empty_attribute_default], hn) + with pytest.deprecated_call(match="Slic"): + self.m(hn[1:-3], ['John', 'P.', 'Doe-Ray'], hn) + + def test_slice_getitem_deprecation_names_issue(self) -> None: + # slice access is deprecated for removal in 2.0 (#258); string-key + # access is unaffected and stays silent + hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") + with pytest.deprecated_call(match="258"): + hn[1:] + with warnings.catch_warnings(): + warnings.simplefilter("error") + hn['first'] def test_getitem(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe, Jr.") @@ -474,20 +487,30 @@ def test_getitem(self) -> None: def test_setitem(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe, Jr.") - hn['title'] = 'test' + with pytest.deprecated_call(match="258"): + hn['title'] = 'test' self.m(hn['title'], "test", hn) - hn['last'] = ['test', 'test2'] + with pytest.deprecated_call(match="258"): + hn['last'] = ['test', 'test2'] self.m(hn['last'], "test test2", hn) - with pytest.raises(TypeError): + with pytest.raises(TypeError), pytest.deprecated_call(): hn["suffix"] = [['test']] # type: ignore[list-item] - with pytest.raises(TypeError): + with pytest.raises(TypeError), pytest.deprecated_call(): hn["suffix"] = {"test": "test"} # type: ignore[assignment] def test_setitem_invalid_key_raises_keyerror(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe, Jr.") - with pytest.raises(KeyError): + with pytest.raises(KeyError), pytest.deprecated_call(): hn["bogus"] = "value" + def test_setitem_emits_deprecation_warning_naming_attribute_assignment(self) -> None: + # __setitem__ duplicates plain attribute assignment and is deprecated + # for removal in 2.0 (#258); use hn.first = ... instead + hn = HumanName("Dr. John A. Kenneth Doe, Jr.") + with pytest.deprecated_call(match="attribute assignment"): + hn['first'] = 'Jane' + self.m(hn.first, "Jane", hn) + def test_conjunction_names(self) -> None: hn = HumanName("johnny y") self.m(hn.first, "johnny", hn) @@ -530,7 +553,7 @@ def test_assignment_filters_empty_tokens(self) -> None: hn.middle = ["a", "", "b"] self.assertEqual(hn.middle_list, ["a", "b"]) self.m(hn.middle, "a b", hn) - hn["last"] = ["", "Smith"] + hn.last = ["", "Smith"] self.assertEqual(hn.last_list, ["Smith"]) def test_blank_name(self) -> None: @@ -599,9 +622,15 @@ def test_override_constants(self) -> None: self.assertIs(hn.C, C) def test_override_regex(self) -> None: + # A regexes dict this sparse omits keys the parser reads + # unconditionally (e.g. squash_bidi's 'bidi'); each miss falls back + # to EMPTY_REGEX but now also warns -- unknown-key access is + # deprecated for removal in 2.0 (#256), which would make this build + # AttributeError-crash the parser instead of degrading silently. var = TupleManager([("spaces", re.compile(r"\s+")),]) C = Constants(regexes=var) - hn = HumanName(constants=C) + with pytest.deprecated_call(): + hn = HumanName(constants=C) self.assertTrue(hn.C.regexes == var) def test_override_titles(self) -> None: diff --git a/tests/test_variations.py b/tests/test_variations.py index 6ce0036..249afd8 100644 --- a/tests/test_variations.py +++ b/tests/test_variations.py @@ -1,3 +1,5 @@ +import warnings + from nameparser import HumanName from tests.base import HumanNameTestBase @@ -203,7 +205,11 @@ def test_variations_of_TEST_NAMES(self) -> None: if len(hn.suffix_list) > 1: d = SimpleNamespace(**hn.as_dict()) hn = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix}".split(',')[0]) - hn.C.empty_attribute_default = '' # format strings below require empty string + with warnings.catch_warnings(): + # format strings below require empty string; #255 deprecation + # noise isn't the point of this broad parsing-variation test + warnings.simplefilter("ignore", DeprecationWarning) + hn.C.empty_attribute_default = '' d = SimpleNamespace(**hn.as_dict()) nocomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix}") lastnamecomma = HumanName(f"{d.last}, {d.title} {d.first} {d.middle} {d.suffix}")