From 5bbc1caaf0ebbaedef912a6faaef7f4722034a80 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 12:11:09 -0700 Subject: [PATCH 1/4] test: add assertIs/assertIsNot shims to HumanNameTestBase Co-Authored-By: Claude Sonnet 4.6 --- tests/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/base.py b/tests/base.py index 1f20857..6b09ea1 100644 --- a/tests/base.py +++ b/tests/base.py @@ -41,3 +41,9 @@ def assertIn(self, member: object, container: object, msg: object = None) -> Non def assertNotIn(self, member: object, container: object, msg: object = None) -> None: assert member not in container, msg # type: ignore[operator] + + def assertIs(self, first: object, second: object, msg: object = None) -> None: + assert first is second, msg or f"{first!r} is not {second!r}" + + def assertIsNot(self, first: object, second: object, msg: object = None) -> None: + assert first is not second, msg or f"{first!r} is {second!r}" From 9939b1f9d02e458b1a09e11eb625bb7e0e8597af Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 12:11:56 -0700 Subject: [PATCH 2/4] test: use assertIs/assertIsNot shims in existing identity checks Co-Authored-By: Claude Sonnet 4.6 --- tests/test_python_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 3e6baca..0baf30f 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -10,7 +10,7 @@ dill = False # type: ignore[assignment] from nameparser import HumanName -from nameparser.config import Constants, TupleManager +from nameparser.config import CONSTANTS, Constants, TupleManager from tests.base import HumanNameTestBase @@ -96,7 +96,7 @@ def test_comparison(self) -> None: hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") self.assertTrue(hn1 == hn2) - self.assertTrue(hn1 is not hn2) + self.assertIsNot(hn1, hn2) self.assertTrue(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC") hn1 = HumanName("Doe, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") @@ -156,7 +156,7 @@ def test_comparison_case_insensitive(self) -> None: hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC") self.assertTrue(hn1 == hn2) - self.assertTrue(hn1 is not hn2) + self.assertIsNot(hn1, hn2) self.assertTrue(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC") def test_slice(self) -> None: @@ -222,7 +222,7 @@ def test_is_conjunction_with_list(self) -> None: def test_override_constants(self) -> None: C = Constants() hn = HumanName(constants=C) - self.assertTrue(hn.C is C) + self.assertIs(hn.C, C) def test_override_regex(self) -> None: var = TupleManager([("spaces", re.compile(r"\s+", re.U)),]) From bd28a4ee9945bbc5359101fa0d067c2354d825b6 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 12:12:15 -0700 Subject: [PATCH 3/4] fix: restore CONSTANTS singleton identity across pickle/deepcopy (closes #169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A default HumanName shares the module-level CONSTANTS singleton as its .C. pickle and copy.deepcopy cannot preserve object identity, so without custom hooks .C was serialized by value — causing has_own_config to flip to True and bloating every serialized default name with a full Constants copy. __getstate__ replaces .C with None as a sentinel when it is the shared singleton; __setstate__ restores the CONSTANTS reference from that sentinel. None is a safe sentinel because the constructor always replaces a None constants argument with a fresh Constants(). Co-Authored-By: Claude Sonnet 4.6 --- nameparser/parser.py | 11 +++++++++++ tests/test_python_api.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/nameparser/parser.py b/nameparser/parser.py index 27e350c..b0cc008 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -121,6 +121,17 @@ def __init__( # full_name setter triggers the parse self.full_name = full_name + def __getstate__(self) -> dict: + state = self.__dict__.copy() + if state.get('C') is CONSTANTS: + state['C'] = None # sentinel: restore shared singleton on load + return state + + def __setstate__(self, state: dict) -> None: + if state.get('C') is None: + state['C'] = CONSTANTS + self.__dict__.update(state) + def __iter__(self) -> Iterator[str]: return self diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 0baf30f..9ebd6fd 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -92,6 +92,32 @@ def test_name_instance_deepcopy_isolates_instance_config(self) -> None: self.assertIn('chancellor', dup.C.titles) self.assertNotIn('marker', hn.C.titles) + def test_pickle_default_name_preserves_singleton_identity(self) -> None: + """A default HumanName must re-attach to CONSTANTS after a pickle round-trip. + + Without __getstate__/__setstate__, pickle serializes .C by value, so the + restored name gets a detached copy — has_own_config flips to True and + every pickled default name carries a full Constants copy. + """ + hn = HumanName("John Doe") + self.assertFalse(hn.has_own_config) + self.assertIs(hn.C, CONSTANTS) + + # Safe: round-tripping an object we just built, not untrusted data. + restored = pickle.loads(pickle.dumps(hn)) + + self.assertIs(restored.C, CONSTANTS) + self.assertFalse(restored.has_own_config) + + def test_deepcopy_default_name_preserves_singleton_identity(self) -> None: + """copy.deepcopy of a default HumanName must re-attach to CONSTANTS.""" + hn = HumanName("John Doe") + + dup = copy.deepcopy(hn) + + self.assertIs(dup.C, CONSTANTS) + self.assertFalse(dup.has_own_config) + def test_comparison(self) -> None: hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") From 3c3c04f16788d3f5334bd2930d1fe02cf154c322 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 12:23:47 -0700 Subject: [PATCH 4/4] test: strengthen singleton identity tests with field and copy.copy coverage - Assert parsed fields (str, first, last) survive pickle/deepcopy round-trips alongside the existing singleton-identity checks - Add test_pickle_instance_config_name_preserves_own_config: pins that instance-config names are not collapsed onto CONSTANTS after pickle - Add test_shallow_copy_default_name_preserves_singleton_identity: documents that copy.copy shares the CONSTANTS reference without needing __getstate__ Co-Authored-By: Claude Sonnet 4.6 --- tests/test_python_api.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 9ebd6fd..8602aef 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -108,6 +108,33 @@ def test_pickle_default_name_preserves_singleton_identity(self) -> None: self.assertIs(restored.C, CONSTANTS) self.assertFalse(restored.has_own_config) + self.assertEqual(str(restored), str(hn)) + self.assertEqual(restored.first, hn.first) + self.assertEqual(restored.last, hn.last) + + 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.C.titles.add('chancellor') + hn.parse_full_name() + self.assertTrue(hn.has_own_config) + self.assertIsNot(hn.C, CONSTANTS) + + # Safe: round-tripping a HumanName the test just built, not untrusted data. + restored = pickle.loads(pickle.dumps(hn)) + + self.assertTrue(restored.has_own_config) + self.assertIsNot(restored.C, CONSTANTS) + self.assertIn('chancellor', restored.C.titles) + + def test_shallow_copy_default_name_preserves_singleton_identity(self) -> None: + """copy.copy of a default HumanName shares the CONSTANTS reference without hooks.""" + hn = HumanName("John Doe") + + sc = copy.copy(hn) + + self.assertIs(sc.C, CONSTANTS) + self.assertFalse(sc.has_own_config) def test_deepcopy_default_name_preserves_singleton_identity(self) -> None: """copy.deepcopy of a default HumanName must re-attach to CONSTANTS.""" @@ -117,6 +144,9 @@ def test_deepcopy_default_name_preserves_singleton_identity(self) -> None: self.assertIs(dup.C, CONSTANTS) self.assertFalse(dup.has_own_config) + self.assertEqual(str(dup), str(hn)) + self.assertEqual(dup.first, hn.first) + self.assertEqual(dup.last, hn.last) def test_comparison(self) -> None: hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")