Skip to content

Commit 7686567

Browse files
authored
Merge pull request #170 from derek73/fix/issue-169-singleton-identity
fix: restore CONSTANTS singleton identity across pickle/deepcopy
2 parents 8b63454 + 3c3c04f commit 7686567

3 files changed

Lines changed: 77 additions & 4 deletions

File tree

nameparser/parser.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ def __init__(
121121
# full_name setter triggers the parse
122122
self.full_name = full_name
123123

124+
def __getstate__(self) -> dict:
125+
state = self.__dict__.copy()
126+
if state.get('C') is CONSTANTS:
127+
state['C'] = None # sentinel: restore shared singleton on load
128+
return state
129+
130+
def __setstate__(self, state: dict) -> None:
131+
if state.get('C') is None:
132+
state['C'] = CONSTANTS
133+
self.__dict__.update(state)
134+
124135
def __iter__(self) -> Iterator[str]:
125136
return self
126137

tests/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,9 @@ def assertIn(self, member: object, container: object, msg: object = None) -> Non
4141

4242
def assertNotIn(self, member: object, container: object, msg: object = None) -> None:
4343
assert member not in container, msg # type: ignore[operator]
44+
45+
def assertIs(self, first: object, second: object, msg: object = None) -> None:
46+
assert first is second, msg or f"{first!r} is not {second!r}"
47+
48+
def assertIsNot(self, first: object, second: object, msg: object = None) -> None:
49+
assert first is not second, msg or f"{first!r} is {second!r}"

tests/test_python_api.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
dill = False # type: ignore[assignment]
1111

1212
from nameparser import HumanName
13-
from nameparser.config import Constants, TupleManager
13+
from nameparser.config import CONSTANTS, Constants, TupleManager
1414

1515
from tests.base import HumanNameTestBase
1616

@@ -92,11 +92,67 @@ def test_name_instance_deepcopy_isolates_instance_config(self) -> None:
9292
self.assertIn('chancellor', dup.C.titles)
9393
self.assertNotIn('marker', hn.C.titles)
9494

95+
def test_pickle_default_name_preserves_singleton_identity(self) -> None:
96+
"""A default HumanName must re-attach to CONSTANTS after a pickle round-trip.
97+
98+
Without __getstate__/__setstate__, pickle serializes .C by value, so the
99+
restored name gets a detached copy — has_own_config flips to True and
100+
every pickled default name carries a full Constants copy.
101+
"""
102+
hn = HumanName("John Doe")
103+
self.assertFalse(hn.has_own_config)
104+
self.assertIs(hn.C, CONSTANTS)
105+
106+
# Safe: round-tripping an object we just built, not untrusted data.
107+
restored = pickle.loads(pickle.dumps(hn))
108+
109+
self.assertIs(restored.C, CONSTANTS)
110+
self.assertFalse(restored.has_own_config)
111+
self.assertEqual(str(restored), str(hn))
112+
self.assertEqual(restored.first, hn.first)
113+
self.assertEqual(restored.last, hn.last)
114+
115+
def test_pickle_instance_config_name_preserves_own_config(self) -> None:
116+
"""A HumanName with its own Constants must not be collapsed onto CONSTANTS after pickle."""
117+
hn = HumanName("Smith, Dr. John", None)
118+
hn.C.titles.add('chancellor')
119+
hn.parse_full_name()
120+
self.assertTrue(hn.has_own_config)
121+
self.assertIsNot(hn.C, CONSTANTS)
122+
123+
# Safe: round-tripping a HumanName the test just built, not untrusted data.
124+
restored = pickle.loads(pickle.dumps(hn))
125+
126+
self.assertTrue(restored.has_own_config)
127+
self.assertIsNot(restored.C, CONSTANTS)
128+
self.assertIn('chancellor', restored.C.titles)
129+
130+
def test_shallow_copy_default_name_preserves_singleton_identity(self) -> None:
131+
"""copy.copy of a default HumanName shares the CONSTANTS reference without hooks."""
132+
hn = HumanName("John Doe")
133+
134+
sc = copy.copy(hn)
135+
136+
self.assertIs(sc.C, CONSTANTS)
137+
self.assertFalse(sc.has_own_config)
138+
139+
def test_deepcopy_default_name_preserves_singleton_identity(self) -> None:
140+
"""copy.deepcopy of a default HumanName must re-attach to CONSTANTS."""
141+
hn = HumanName("John Doe")
142+
143+
dup = copy.deepcopy(hn)
144+
145+
self.assertIs(dup.C, CONSTANTS)
146+
self.assertFalse(dup.has_own_config)
147+
self.assertEqual(str(dup), str(hn))
148+
self.assertEqual(dup.first, hn.first)
149+
self.assertEqual(dup.last, hn.last)
150+
95151
def test_comparison(self) -> None:
96152
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
97153
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")
98154
self.assertTrue(hn1 == hn2)
99-
self.assertTrue(hn1 is not hn2)
155+
self.assertIsNot(hn1, hn2)
100156
self.assertTrue(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC")
101157
hn1 = HumanName("Doe, Dr. John P., CLU, CFP, LUTC")
102158
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")
@@ -156,7 +212,7 @@ def test_comparison_case_insensitive(self) -> None:
156212
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
157213
hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC")
158214
self.assertTrue(hn1 == hn2)
159-
self.assertTrue(hn1 is not hn2)
215+
self.assertIsNot(hn1, hn2)
160216
self.assertTrue(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC")
161217

162218
def test_slice(self) -> None:
@@ -222,7 +278,7 @@ def test_is_conjunction_with_list(self) -> None:
222278
def test_override_constants(self) -> None:
223279
C = Constants()
224280
hn = HumanName(constants=C)
225-
self.assertTrue(hn.C is C)
281+
self.assertIs(hn.C, C)
226282

227283
def test_override_regex(self) -> None:
228284
var = TupleManager([("spaces", re.compile(r"\s+", re.U)),])

0 commit comments

Comments
 (0)