From 310a793d81110a363e329c30a4931f9ee8f2bdb5 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 11 Jul 2026 18:43:20 -0700 Subject: [PATCH 1/2] Add Constants.copy(); deprecate constants=None constants=None means "build a fresh private Constants()" -- a sentinel that means the opposite of what None conventionally means, since the default is the *shared* CONSTANTS. It's an easy trap: customize CONSTANTS, later pass None elsewhere for "my own config", and those customizations silently vanish with no error. Constants.copy() gives an explicit spelling for the missing answer -- a private snapshot of the current shared config, as opposed to Constants()'s fresh library defaults. Passing constants=None still works but now emits a DeprecationWarning naming both replacements; removal is #261 (2.0). Migrates every internal constants=None / HumanName(name, None) usage in tests and docs to the explicit spellings so the suite's own output stays warning-free. Fixes #260. --- docs/customize.rst | 39 ++++++++++--- docs/release_log.rst | 2 + nameparser/config/__init__.py | 35 +++++++++--- nameparser/parser.py | 41 +++++++++++--- tests/test_constants.py | 103 +++++++++++++++++++++++++++++----- tests/test_initials.py | 5 +- tests/test_nicknames.py | 10 ++-- tests/test_output_format.py | 5 +- tests/test_python_api.py | 6 +- 9 files changed, 198 insertions(+), 48 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index 7397c68..7b05120 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -79,7 +79,8 @@ To recognize an *additional* delimiter, add a compiled pattern to >>> import re >>> from nameparser import HumanName - >>> hn = HumanName("Benjamin {Ben} Franklin", constants=None) + >>> from nameparser.config import Constants + >>> hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) >>> hn.nickname '' >>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) @@ -455,23 +456,37 @@ e.g. parsing names on multiple threads. ]> -If you'd prefer new instances to have their own config values, one shortcut is to pass -``None`` as the second argument (or ``constants`` keyword argument) when -instantiating ``HumanName``. Each instance always has a ``C`` attribute, but if -you didn't pass ``None`` (or your own :py:class:`~nameparser.config.Constants` -instance) to the ``constants`` argument then it's a reference to the -module-level config values with the behavior described above. +If you'd prefer new instances to have their own config values, pass your own +:py:class:`~nameparser.config.Constants` instance as the ``constants`` +argument when instantiating ``HumanName``. There are three spellings, +depending on which config you want the new instance to start from: + +.. code-block:: + + HumanName(name) # shared CONSTANTS (unchanged) + HumanName(name, constants=Constants()) # private, fresh library defaults + HumanName(name, constants=CONSTANTS.copy()) # private, snapshot of the current shared config + +The middle and last forms both give the instance an independent config that +further changes to ``CONSTANTS`` won't reach, but they answer different +questions: ``Constants()`` ignores any customization already made to +``CONSTANTS`` and starts clean, while ``CONSTANTS.copy()`` carries those +customizations over into the private copy. Each instance always has a ``C`` +attribute, but if you didn't pass one of the private forms to the +``constants`` argument then it's a reference to the module-level config +values with the behavior described above. .. doctest:: module config :options: +ELLIPSIS, +NORMALIZE_WHITESPACE >>> from nameparser import HumanName + >>> from nameparser.config import Constants >>> instance = HumanName("Dean Robert Johns") >>> instance.has_own_config False >>> instance.C.titles.add('dean') SetManager({'10th', ..., 'zoologist'}) - >>> other_instance = HumanName("Dean Robert Johns", None) # <-- pass None for per-instance config + >>> other_instance = HumanName("Dean Robert Johns", Constants()) # <-- fresh, private config >>> other_instance >> other_instance.has_own_config True +.. deprecated:: 1.4.0 + Passing ``None`` as the ``constants`` argument also builds a fresh + ``Constants()``, but is deprecated: ``None`` conventionally means "use + the default," which here is the *shared* ``CONSTANTS`` -- the opposite of + what passing ``None`` actually does. It emits a ``DeprecationWarning`` + and will raise ``TypeError`` in 2.0 (issue #260); use one of the two + explicit forms above instead. + Don't Remove Emojis ~~~~~~~~~~~~~~~~~~~ diff --git a/docs/release_log.rst b/docs/release_log.rst index 260b6fd..08e5e88 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -2,6 +2,8 @@ Release Log =========== * 1.4.0 - Unreleased + - 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) - 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/nameparser/config/__init__.py b/nameparser/config/__init__.py index 35ab9fd..342fd6e 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -12,20 +12,29 @@ >>> CONSTANTS.titles.remove('hon').add('chemistry','dean') # doctest: +SKIP You can also adjust the configuration of individual instances by passing -``None`` as the second argument upon instantiation. +your own :py:class:`Constants` instance as the second argument upon +instantiation -- ``Constants()`` for fresh library defaults, or +``CONSTANTS.copy()`` for a private snapshot of the current module config. :: >>> from nameparser import HumanName - >>> hn = HumanName("Dean Robert Johns", None) + >>> from nameparser.config import Constants + >>> hn = HumanName("Dean Robert Johns", Constants()) >>> hn.C.titles.add('dean') # doctest: +SKIP >>> hn.parse_full_name() # need to run this again after config changes -**Potential Gotcha**: If you do not pass ``None`` (or your own -:py:class:`Constants` instance) as the second argument, ``hn.C`` will be a -reference to the module config, possibly yielding unexpected results. See -`Customizing the Parser `_. +**Potential Gotcha**: If you do not pass your own :py:class:`Constants` +instance as the second argument, ``hn.C`` will be a reference to the module +config, possibly yielding unexpected results. See `Customizing the Parser +`_. + +.. deprecated:: 1.4.0 + Passing ``None`` as the second argument also builds a fresh + ``Constants()``, but is deprecated in favor of the explicit spellings + above; it will raise ``TypeError`` in 2.0 (issue #260). """ +import copy import re import sys import warnings @@ -327,7 +336,7 @@ def _is_dunder(attr: str) -> bool: # validate and normalize each one exactly once at import. Constants() # copies these via _normalized_elements' SetManager fast path instead of # re-checking ~1,400 elements per construction — a cost that otherwise -# repeats on the per-instance-config path, HumanName(constants=None). +# repeats on the per-instance-config path, HumanName(constants=Constants()). # # This snapshot is taken once, at import time: mutating a raw constant # (e.g. `TITLES.add('x')`) after import is *not* picked up by Constants() @@ -806,6 +815,18 @@ def __repr__(self) -> str: ] return "" + def copy(self) -> 'Constants': + """ + Return a detached deep copy of this ``Constants`` instance, preserving + its current customizations -- unlike :py:class:`Constants`'s own + constructor, which always starts from library defaults. Useful for + snapshotting the shared module-level ``CONSTANTS`` (including + whatever it's been customized with) into a private instance, e.g. + ``CONSTANTS.copy()``. Relies on the same ``__getstate__``/``__setstate__`` + pair pickling uses, so it's as cheap and correct as pickle round-tripping. + """ + return copy.deepcopy(self) + def __setstate__(self, state: Mapping[str, Any]) -> None: # Restore each saved attribute directly. The previous implementation # passed the whole state dict to __init__ as the ``prefixes`` argument, diff --git a/nameparser/parser.py b/nameparser/parser.py index 81fd625..8aaa1e9 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -51,8 +51,14 @@ class HumanName: :param str full_name: The name string to be parsed. :param constants: a :py:class:`~nameparser.config.Constants` instance (subclasses are - honored). Pass ``None`` for `per-instance config `_. - Anything else raises ``TypeError``. + honored). Defaults to the shared module-level ``CONSTANTS``. For + `per-instance config `_, pass ``Constants()`` for + fresh library defaults, or ``CONSTANTS.copy()`` for a private + snapshot of the current shared config. Passing ``None`` also builds + a fresh ``Constants()``, but is deprecated (warns; raises + ``TypeError`` in 2.0, see issue #260) since it silently discards any + customizations the caller may have expected to carry over. 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) @@ -106,7 +112,10 @@ def __init__( nickname: str | list[str] | None = None, maiden: str | list[str] | None = None, ) -> None: - self.C = constants + # calls _validate_constants directly (not through the C setter) so + # the deprecation warning below attributes to this constructor's + # caller rather than to the setter, mirroring _apply_full_name below + self._C = self._validate_constants(constants, stacklevel=3) # Lookup entries derived while parsing this instance (period-joined # titles/suffixes like "Lt.Gov.", conjunction-joined pieces like @@ -143,11 +152,27 @@ def __init__( self._apply_full_name(full_name, stacklevel=3) @staticmethod - def _validate_constants(constants: 'Constants | None') -> 'Constants': + def _validate_constants(constants: 'Constants | None', *, stacklevel: int) -> 'Constants': # Shared by the constructor and the C setter so both assignment paths # give the same immediate TypeError instead of one bypassing the # other and failing far from the cause (#239). if constants is None: + # deprecated 1.4.0, raises TypeError in 2.0 (#260, removal #261): + # None means "build a fresh private Constants()", the opposite of + # what None conventionally means (the default is the *shared* + # CONSTANTS) -- an easy trap since customizing CONSTANTS then + # passing None elsewhere silently drops those customizations with + # no error. CONSTANTS.copy() is the explicit spelling for the + # other reading: a private snapshot of the current shared config. + warnings.warn( + "Passing constants=None is deprecated and will raise " + "TypeError in 2.0; use constants=Constants() for fresh " + "library defaults, or constants=CONSTANTS.copy() to snapshot " + "the current shared config. See " + "https://github.com/derek73/python-nameparser/issues/260", + DeprecationWarning, + stacklevel=stacklevel, + ) return Constants() if not isinstance(constants, Constants): # passing the class itself is the likeliest mistake, and @@ -169,14 +194,16 @@ def C(self) -> 'Constants': `_. Assigning a non-``Constants`` value (besides ``None``, which builds a - fresh private ``Constants()``) raises the same ``TypeError`` as passing - an invalid ``constants`` argument to the constructor (#239). + fresh private ``Constants()`` and emits a ``DeprecationWarning`` -- + see :py:meth:`~nameparser.parser.HumanName.__init__`) raises the same + ``TypeError`` as passing an invalid ``constants`` argument to the + constructor (#239). """ return self._C @C.setter def C(self, constants: 'Constants | None') -> None: - self._C = self._validate_constants(constants) + self._C = self._validate_constants(constants, stacklevel=3) def __getstate__(self) -> dict: state = self.__dict__.copy() diff --git a/tests/test_constants.py b/tests/test_constants.py index 6f65125..b4003e8 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -18,7 +18,7 @@ class ConstantsCustomizationTests(HumanNameTestBase): def test_add_title(self) -> None: - hn = HumanName("Te Awanui-a-Rangi Black", constants=None) + hn = HumanName("Te Awanui-a-Rangi Black", constants=Constants()) start_len = len(hn.C.titles) self.assertTrue(start_len > 0) hn.C.titles.add('te') @@ -63,7 +63,8 @@ def test_assigning_constants_class_after_construction_raises_with_hint(self) -> def test_assigning_none_to_constants_after_construction_builds_new_instance(self) -> None: hn = HumanName("John Doe") - hn.C = None + with pytest.deprecated_call(): + hn.C = None self.assertIsNot(hn.C, CONSTANTS) self.assertTrue(isinstance(hn.C, Constants)) @@ -227,7 +228,7 @@ def test_tuplemanager_accepts_mapping_and_pairs(self) -> None: self.assertEqual(tm2.b, '2') def test_remove_title(self) -> None: - hn = HumanName("Hon Solo", constants=None) + hn = HumanName("Hon Solo", constants=Constants()) start_len = len(hn.C.titles) self.assertTrue(start_len > 0) hn.C.titles.remove('hon') @@ -237,7 +238,7 @@ def test_remove_title(self) -> None: self.m(hn.last, "Solo", hn) def test_add_multiple_arguments(self) -> None: - hn = HumanName("Assoc Dean of Chemistry Robert Johns", constants=None) + hn = HumanName("Assoc Dean of Chemistry Robert Johns", constants=Constants()) hn.C.titles.add('dean', 'Chemistry') hn.parse_full_name() self.m(hn.title, "Assoc Dean of Chemistry", hn) @@ -245,7 +246,7 @@ def test_add_multiple_arguments(self) -> None: self.m(hn.last, "Johns", hn) def test_instances_can_have_own_constants(self) -> None: - hn = HumanName("", None) + hn = HumanName("", Constants()) hn2 = HumanName("") hn.C.titles.remove('hon') self.assertEqual('hon' in hn.C.titles, False) @@ -276,7 +277,7 @@ def test_can_add_global_nickname_delimiter(self) -> None: # nickname_delimiters) around every test. def test_remove_multiple_arguments(self) -> None: - hn = HumanName("Ms Hon Solo", constants=None) + hn = HumanName("Ms Hon Solo", constants=Constants()) hn.C.titles.remove('hon', 'ms') hn.parse_full_name() self.m(hn.first, "Ms", hn) @@ -284,7 +285,7 @@ def test_remove_multiple_arguments(self) -> None: self.m(hn.last, "Solo", hn) def test_chain_multiple_arguments(self) -> None: - hn = HumanName("Dean Ms Hon Solo", constants=None) + hn = HumanName("Dean Ms Hon Solo", constants=Constants()) hn.C.titles.remove('hon', 'ms').add('dean') hn.parse_full_name() self.m(hn.title, "Dean", hn) @@ -293,7 +294,7 @@ def test_chain_multiple_arguments(self) -> None: self.m(hn.last, "Solo", hn) def test_clear_removes_all_entries(self) -> None: - hn = HumanName("Ms Hon Solo", constants=None) + hn = HumanName("Ms Hon Solo", constants=Constants()) hn.C.titles.clear() hn.parse_full_name() self.m(hn.first, "Ms", hn) @@ -318,7 +319,7 @@ def test_empty_attribute_default(self) -> None: self.m(hn.nickname, None, hn) def test_empty_attribute_on_instance(self) -> None: - hn = HumanName("", None) + hn = HumanName("", Constants()) 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) @@ -328,7 +329,7 @@ def test_empty_attribute_on_instance(self) -> None: self.m(hn.nickname, None, hn) def test_none_empty_attribute_string_formatting(self) -> None: - hn = HumanName("", None) + hn = HumanName("", Constants()) hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above self.assertEqual('', str(hn), hn) @@ -660,14 +661,14 @@ def test_pickle_roundtrip_rewires_invalidation_callbacks(self) -> None: def test_is_rootname_consistent_with_is_title(self) -> None: """is_rootname must return False for words recognised by is_title.""" - hn = HumanName("", constants=None) + hn = HumanName("", constants=Constants()) _ = hn.C.suffixes_prefixes_titles # prime the cache so a stale entry would be observable hn.C.titles.add('emerita') self.assertFalse(hn.is_rootname('emerita')) def test_is_rootname_consistent_with_is_prefix(self) -> None: """is_rootname must return False for words recognised by is_prefix.""" - hn = HumanName("", constants=None) + hn = HumanName("", constants=Constants()) _ = hn.C.suffixes_prefixes_titles # prime the cache so a stale entry would be observable hn.C.prefixes.add('xpfx') self.assertFalse(hn.is_rootname('xpfx')) @@ -870,7 +871,7 @@ def test_prefix_conjunction_join_does_not_leak_into_prefixes(self) -> None: self.m(hn.last, "von und zu Liechtenstein", hn) def test_instance_owned_constants_not_mutated_by_parsing(self) -> None: - hn = HumanName("", constants=None) + hn = HumanName("", constants=Constants()) before = self._config_snapshot(hn.C) hn.full_name = "Lt.Gov. John Doe" self._assert_config_unchanged(hn.C, before, "Lt.Gov. John Doe") @@ -968,4 +969,78 @@ def test_repr_reports_empty_collection(self) -> None: def test_repr_is_bracketed_multiline(self) -> None: repr_str = repr(Constants()) self.assertTrue(repr_str.startswith("")) + + +class ConstantsCopyTests(HumanNameTestBase): + """Constants.copy() -- a detached snapshot, distinct from fresh Constants() defaults (#260).""" + + def test_copy_is_independent_of_original(self) -> None: + c = Constants() + dup = c.copy() + dup.titles.add('a-brand-new-title-for-copy-test') + self.assertNotIn('a-brand-new-title-for-copy-test', c.titles) + + def test_copy_is_not_the_same_object(self) -> None: + c = Constants() + dup = c.copy() + self.assertIsNot(dup, c) + self.assertTrue(isinstance(dup, Constants)) + + def test_copy_snapshots_current_customizations(self) -> None: + # Unlike Constants(), which always starts from library defaults, + # .copy() preserves whatever customizations the original already has. + c = Constants() + c.titles.add('zephyrmark') + dup = c.copy() + self.assertIn('zephyrmark', dup.titles) + # and stays a snapshot -- later mutation of the original doesn't leak in + c.titles.remove('zephyrmark') + self.assertIn('zephyrmark', dup.titles) + + def test_fresh_constants_does_not_include_source_customizations(self) -> None: + # Contrast case for the snapshot test above: Constants() ignores + # whatever CONSTANTS has been customized with. + c = Constants() + c.titles.add('zephyrmark') + fresh = Constants() + self.assertNotIn('zephyrmark', fresh.titles) + + +class ConstantsNoneDeprecationTests(HumanNameTestBase): + """constants=None is deprecated in favor of Constants() or CONSTANTS.copy() (#260).""" + + def test_explicit_none_warns_on_construction(self) -> None: + with pytest.deprecated_call(match="Constants()"): + HumanName("John Doe", constants=None) + + def test_explicit_none_warns_on_positional_argument(self) -> None: + with pytest.deprecated_call(match="Constants()"): + HumanName("John Doe", None) + + def test_explicit_none_warning_names_both_replacements(self) -> None: + with pytest.warns(DeprecationWarning) as record: + HumanName("John Doe", constants=None) + message = str(record[0].message) + self.assertIn("Constants()", message) + self.assertIn("CONSTANTS.copy()", message) + + def test_explicit_none_warns_on_c_setter(self) -> None: + hn = HumanName("John Doe") + with pytest.deprecated_call(match="Constants()"): + hn.C = None + + def test_omitted_constants_argument_does_not_warn(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + HumanName("John Doe") + + def test_explicit_own_constants_instance_does_not_warn(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + HumanName("John Doe", constants=Constants()) + + def test_explicit_none_still_produces_a_working_private_config(self) -> None: + # Behavior is unchanged, only newly warned about. + with pytest.deprecated_call(): + hn = HumanName("John Doe", constants=None) + self.assertTrue(hn.has_own_config) diff --git a/tests/test_initials.py b/tests/test_initials.py index c70fea3..dabebe5 100644 --- a/tests/test_initials.py +++ b/tests/test_initials.py @@ -1,4 +1,5 @@ from nameparser import HumanName +from nameparser.config import Constants from tests.base import HumanNameTestBase @@ -24,7 +25,7 @@ def test_initials_empty_part_with_none_default_not_literal_none(self) -> None: # Regression: when empty_attribute_default is None, an empty name part # used to be interpolated by str.format as the literal "None" (e.g. # "John Doe" -> "J. None D."). Empty parts must render as ''. - hn = HumanName("John Doe", constants=None) + hn = HumanName("John Doe", constants=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 @@ -39,7 +40,7 @@ def test_initials_all_empty_returns_empty_attribute_default(self) -> None: # Regression: a fully-empty result must fall back to # empty_attribute_default (here None), matching the first/last accessors, # rather than rendering the literal "None None None". - hn = HumanName("", constants=None) + hn = HumanName("", constants=Constants()) hn.C.empty_attribute_default = None # type: ignore[assignment] # see test above self.assertEqual(hn.initials(), None) diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index 8d86b23..07c0083 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -19,7 +19,7 @@ def test_nickname_in_parenthesis(self) -> None: # https://github.com/derek73/python-nameparser/issues/112 def test_add_custom_nickname_delimiter(self) -> None: - hn = HumanName("Benjamin {Ben} Franklin", constants=None) + hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) # curly braces aren't a recognized delimiter by default self.m(hn.nickname, "", hn) hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') @@ -29,7 +29,7 @@ def test_add_custom_nickname_delimiter(self) -> None: self.m(hn.nickname, "Ben", hn) def test_remove_custom_nickname_delimiter(self) -> None: - hn = HumanName("Benjamin {Ben} Franklin", constants=None) + hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn.parse_full_name() self.m(hn.nickname, "Ben", hn) @@ -40,7 +40,7 @@ def test_remove_custom_nickname_delimiter(self) -> None: def test_multiple_custom_nickname_delimiters_together(self) -> None: # Two extras registered at once must both be recognized in a single # parse, independent of insertion order. - hn = HumanName("Benjamin {Ben} Franklin", constants=None) + hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn.C.nickname_delimiters['angle_brackets'] = re.compile(r'<(.*?)>') hn.parse_full_name() @@ -53,7 +53,7 @@ def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None: # directly) must keep working: nickname_delimiters' three built-in # entries resolve self.C.regexes. live at parse time rather than # storing a snapshotted pattern. - hn = HumanName("Benjamin [Ben] Franklin", constants=None) + hn = HumanName("Benjamin [Ben] Franklin", constants=Constants()) self.m(hn.nickname, "", hn) hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]') hn.parse_full_name() @@ -194,7 +194,7 @@ def test_ambiguous_suffix_acronym_in_custom_delimiter_stays_nickname(self) -> No # custom delimiter added via nickname_delimiters -- confirms # handle_match() is applied uniformly regardless of which delimiter # matched, not just the three built-ins. - hn = HumanName("JEFFREY {JD} BRICKEN", constants=None) + hn = HumanName("JEFFREY {JD} BRICKEN", constants=Constants()) hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn.parse_full_name() self.m(hn.nickname, "JD", hn) diff --git a/tests/test_output_format.py b/tests/test_output_format.py index afb0d54..b49f21f 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -1,4 +1,5 @@ from nameparser import HumanName +from nameparser.config import Constants from tests.base import HumanNameTestBase @@ -103,14 +104,14 @@ def test_name_containing_none_substring_with_none_empty_attribute_default(self) # Regression for #254: with empty_attribute_default = None, __str__ # scrubbed the literal string 'None' from the formatted output, # corrupting real name text containing that substring. - hn = HumanName("Nonez Smith", None) + hn = HumanName("Nonez Smith", Constants()) 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", None) + hn = HumanName("None Smith", Constants()) hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default self.assertEqual(str(hn), "None Smith") diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 0ea9878..61e53c3 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -121,7 +121,7 @@ def test_name_instance_pickle_preserves_instance_config(self) -> None: differently-configured parser on the other side. """ # Passing None as the second argument gives this name its own Constants. - hn = HumanName("Smith, Dr. John", None) + hn = HumanName("Smith, Dr. John", Constants()) hn.C.titles.add('chancellor') hn.parse_full_name() @@ -148,7 +148,7 @@ def test_name_instance_deepcopy(self) -> None: def test_name_instance_deepcopy_isolates_instance_config(self) -> None: """A deep-copied HumanName with its own config must be independent.""" - hn = HumanName("Smith, Dr. John", None) + hn = HumanName("Smith, Dr. John", Constants()) hn.C.titles.add('chancellor') dup = copy.deepcopy(hn) @@ -200,7 +200,7 @@ def test_pickle_default_name_preserves_singleton_identity(self) -> None: def test_pickle_instance_config_name_preserves_own_config(self) -> None: """A HumanName with its own Constants must not be collapsed onto CONSTANTS after pickle.""" - hn = HumanName("Smith, Dr. John", None) + hn = HumanName("Smith, Dr. John", Constants()) hn.C.titles.add('chancellor') hn.parse_full_name() self.assertTrue(hn.has_own_config) From a0933bca0fc1a780e784cf22ba28401cc65c7309 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 11 Jul 2026 18:46:19 -0700 Subject: [PATCH 2/2] Test Constants.copy() preserves subclass type copy() is deepcopy-based (restored via __getstate__/__setstate__, not by re-invoking type(self)(...)), so a naive reimplementation could silently downgrade a subclass instance to plain Constants with no existing test catching it. Found in review of #282. --- tests/test_constants.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_constants.py b/tests/test_constants.py index b4003e8..187024a 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -986,6 +986,17 @@ def test_copy_is_not_the_same_object(self) -> None: self.assertIsNot(dup, c) self.assertTrue(isinstance(dup, Constants)) + def test_copy_preserves_subclass_type(self) -> None: + # copy() is deepcopy-based (restored via __getstate__/__setstate__, + # not by re-invoking type(self)(...)), so a naive reimplementation + # could silently downgrade a subclass instance to plain Constants. + class CustomConstants(Constants): + pass + + c = CustomConstants() + dup = c.copy() + self.assertTrue(isinstance(dup, CustomConstants)) + def test_copy_snapshots_current_customizations(self) -> None: # Unlike Constants(), which always starts from library defaults, # .copy() preserves whatever customizations the original already has.