Skip to content

Commit 8f7041c

Browse files
derek73claude
andcommitted
fix: preserve Constants customizations across a pickle round-trip (#167)
__setstate__ passed the entire __getstate__ dict to __init__ as the `prefixes` positional argument, so every collection silently reverted to its module default on unpickling and `prefixes` ended up holding the config attribute names. Restore each saved attribute via setattr instead. __getstate__ now serializes the instance's own state (the __init__ collections plus any instance-level scalar overrides) rather than a dir()-based sweep that also pulled in the computed suffixes_prefixes_titles property (which has no setter and would break __setstate__). Tests: round-trip now preserves added/removed titles and prefixes and instance scalar overrides; both fail against the previous implementation. Add an assertNotIn shim to the test base. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5c19547 commit 8f7041c

3 files changed

Lines changed: 51 additions & 3 deletions

File tree

nameparser/config/__init__.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,22 @@ def __repr__(self) -> str:
274274
return "<Constants() instance>"
275275

276276
def __setstate__(self, state: Mapping[str, Any]) -> None:
277-
Constants.__init__(self, state)
277+
# Restore each saved attribute directly. The previous implementation
278+
# passed the whole state dict to __init__ as the ``prefixes`` argument,
279+
# which silently reverted every collection to its module default on
280+
# unpickling.
281+
self._pst = None
282+
for name, value in state.items():
283+
setattr(self, name, value)
278284

279285
def __getstate__(self) -> Mapping[str, Any]:
280-
attrs = [x for x in dir(self) if not x.startswith('_')]
281-
return dict([(a, getattr(self, a)) for a in attrs])
286+
# Pickle only the instance's own configuration: the collections built in
287+
# __init__ plus any instance-level scalar overrides. Class-level scalar
288+
# defaults are restored by the class itself, and underscore-prefixed
289+
# names such as the ``_pst`` cache are private and rebuilt on demand.
290+
# The computed ``suffixes_prefixes_titles`` property must not be
291+
# serialized — it has no setter and would break __setstate__.
292+
return {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
282293

283294

284295
#: A module-level instance of the :py:class:`Constants()` class.

tests/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ def assertFalse(self, expr: object, msg: object = None) -> None:
3838

3939
def assertIn(self, member: object, container: object, msg: object = None) -> None:
4040
assert member in container, msg # type: ignore[operator]
41+
42+
def assertNotIn(self, member: object, container: object, msg: object = None) -> None:
43+
assert member not in container, msg # type: ignore[operator]

tests/test_constants.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pickle
2+
13
from nameparser import HumanName
24
from nameparser.config import Constants
35

@@ -104,3 +106,35 @@ def test_add_constant_with_explicit_encoding(self) -> None:
104106
c = Constants()
105107
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
106108
self.assertIn('béck', c.titles)
109+
110+
def test_pickle_roundtrip_preserves_customizations(self) -> None:
111+
"""A pickled Constants must restore its customized collections.
112+
113+
Regression test: __setstate__ previously passed the whole state dict
114+
to __init__ as the `prefixes` argument, so every collection silently
115+
reverted to its module default on unpickling.
116+
"""
117+
c = Constants()
118+
c.titles.add('customtitle')
119+
c.prefixes.add('customprefix')
120+
c.titles.remove('hon')
121+
122+
# Safe: round-tripping a Constants the test just built, not untrusted data.
123+
restored = pickle.loads(pickle.dumps(c))
124+
125+
self.assertIn('customtitle', restored.titles)
126+
self.assertIn('customprefix', restored.prefixes)
127+
self.assertNotIn('hon', restored.titles)
128+
# The contributing collections must match the original exactly.
129+
self.assertEqual(set(restored.titles), set(c.titles))
130+
self.assertEqual(set(restored.prefixes), set(c.prefixes))
131+
132+
def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None:
133+
"""An instance-level scalar override must survive a pickle round-trip."""
134+
c = Constants()
135+
c.empty_attribute_default = None
136+
137+
# Safe: round-tripping a Constants the test just built, not untrusted data.
138+
restored = pickle.loads(pickle.dumps(c))
139+
140+
self.assertEqual(restored.empty_attribute_default, None)

0 commit comments

Comments
 (0)