Skip to content

Commit 8b63454

Browse files
authored
Merge pull request #168 from derek73/fix/pickle-roundtrip
Fix pickle/copy round-trip bugs in Constants and config managers
2 parents 5c19547 + 44ed40c commit 8b63454

5 files changed

Lines changed: 226 additions & 23 deletions

File tree

nameparser/config/__init__.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ class TupleManager(dict[str, T]):
126126
'''
127127

128128
def __getattr__(self, attr: str) -> T | None:
129+
# Dunder names are Python's protocol probes (copy looks up __deepcopy__,
130+
# inspect.unwrap looks up __wrapped__, ...), never config keys. Report
131+
# them as genuinely absent so hasattr() is honest and those probes work;
132+
# otherwise the dict default is mistaken for a real protocol hook. See
133+
# RegexTupleManager.__getattr__ for the concrete failure this prevents.
134+
if attr.startswith("__") and attr.endswith("__"):
135+
raise AttributeError(attr)
129136
return self.get(attr)
130137

131138
__setattr__ = dict.__setitem__
@@ -138,11 +145,22 @@ def __setstate__(self, state: Mapping[str, T]) -> None:
138145
self.update(state)
139146

140147
def __reduce__(self) -> tuple[type, tuple[()], Mapping[str, T]]:
141-
return (TupleManager, (), self.__getstate__())
148+
# Use type(self), not TupleManager, so subclasses such as
149+
# RegexTupleManager survive a pickle round-trip instead of being
150+
# downgraded to a plain TupleManager (which loses the EMPTY_REGEX
151+
# default for unknown keys).
152+
return (type(self), (), self.__getstate__())
142153

143154

144155
class RegexTupleManager(TupleManager[re.Pattern[str]]):
145156
def __getattr__(self, attr: str) -> re.Pattern[str]:
157+
# Dunder names are Python's protocol probes (copy.deepcopy looks up
158+
# __deepcopy__, inspect.unwrap looks up __wrapped__, ...), never regex
159+
# keys. Report them as genuinely absent; otherwise the EMPTY_REGEX
160+
# default is mistaken for a real protocol hook — e.g. copy.deepcopy
161+
# tries to call the returned re.Pattern and raises TypeError.
162+
if attr.startswith("__") and attr.endswith("__"):
163+
raise AttributeError(attr)
146164
return self.get(attr, EMPTY_REGEX)
147165

148166

@@ -274,11 +292,33 @@ def __repr__(self) -> str:
274292
return "<Constants() instance>"
275293

276294
def __setstate__(self, state: Mapping[str, Any]) -> None:
277-
Constants.__init__(self, state)
295+
# Restore each saved attribute directly. The previous implementation
296+
# passed the whole state dict to __init__ as the ``prefixes`` argument,
297+
# which silently reverted every collection to its module default on
298+
# unpickling.
299+
self._pst = None
300+
for name, value in state.items():
301+
# Migration shim: pickles written before this fix used a dir() sweep
302+
# for __getstate__, so their state carries the read-only
303+
# ``suffixes_prefixes_titles`` property. Skip any such computed
304+
# property rather than raising AttributeError on its missing setter;
305+
# the real config is restored from the other keys. We don't promise
306+
# to read pre-fix blobs forever — this only smooths migration for
307+
# anyone persisting them, and can be dropped a release or two after
308+
# 1.2.1 once they've re-pickled.
309+
if isinstance(getattr(type(self), name, None), property):
310+
continue
311+
setattr(self, name, value)
278312

279313
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])
314+
# Pickle only the instance's own configuration: the collections built in
315+
# __init__ plus any instance-level scalar overrides. Class-level scalar
316+
# defaults are restored by the class itself, and underscore-prefixed
317+
# names such as the ``_pst`` cache are private and rebuilt on demand.
318+
# Filtering on ``self.__dict__`` also excludes the computed
319+
# ``suffixes_prefixes_titles`` property automatically, since properties
320+
# live on the class and never appear in an instance's ``__dict__``.
321+
return {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
282322

283323

284324
#: 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/conftest.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
import copy
12
from collections.abc import Iterator
23

34
import pytest
45

5-
from nameparser.config import CONSTANTS, SetManager, TupleManager
6-
7-
ConfigCollection = SetManager | TupleManager
6+
from nameparser.config import CONSTANTS
87

98
# Scalar (non-collection) config attributes that individual tests mutate on the
109
# global CONSTANTS singleton. Several tests change these without restoring them;
@@ -38,20 +37,6 @@
3837
)
3938

4039

41-
def _clone_config_collection(value: ConfigCollection) -> ConfigCollection:
42-
"""Return an independent copy of a config collection manager.
43-
44-
``copy.deepcopy`` is not used because ``RegexTupleManager`` carries compiled
45-
patterns that its ``__reduce__`` cannot round-trip. Rebuilding from the
46-
manager's own contents copies the container while sharing the (immutable)
47-
elements, which is all the snapshot needs.
48-
"""
49-
if isinstance(value, SetManager):
50-
return SetManager(set(value))
51-
# TupleManager / RegexTupleManager are dict subclasses.
52-
return type(value)(dict(value))
53-
54-
5540
@pytest.fixture(autouse=True, params=['', None], ids=['default', 'none'])
5641
def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | None]:
5742
"""Run every test under both empty_attribute_default settings, isolating global config.
@@ -66,7 +51,7 @@ def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | No
6651
"""
6752
scalar_snapshot = {attr: getattr(CONSTANTS, attr) for attr in _SCALAR_CONFIG_ATTRS}
6853
collection_snapshot = {
69-
attr: _clone_config_collection(getattr(CONSTANTS, attr))
54+
attr: copy.deepcopy(getattr(CONSTANTS, attr))
7055
for attr in _COLLECTION_CONFIG_ATTRS
7156
}
7257
CONSTANTS.empty_attribute_default = request.param

tests/test_constants.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import copy
2+
import pickle
3+
14
from nameparser import HumanName
2-
from nameparser.config import Constants
5+
from nameparser.config import Constants, RegexTupleManager, SetManager, TupleManager
6+
from nameparser.config.regexes import EMPTY_REGEX
37

48
from tests.base import HumanNameTestBase
59

@@ -104,3 +108,127 @@ def test_add_constant_with_explicit_encoding(self) -> None:
104108
c = Constants()
105109
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
106110
self.assertIn('béck', c.titles)
111+
112+
def test_pickle_roundtrip_preserves_customizations(self) -> None:
113+
"""A pickled Constants must restore its customized collections.
114+
115+
Regression test: __setstate__ previously passed the whole state dict
116+
to __init__ as the `prefixes` argument, so every collection silently
117+
reverted to its module default on unpickling.
118+
"""
119+
c = Constants()
120+
c.titles.add('customtitle')
121+
c.prefixes.add('customprefix')
122+
c.titles.remove('hon')
123+
124+
# Safe: round-tripping a Constants the test just built, not untrusted data.
125+
restored = pickle.loads(pickle.dumps(c))
126+
127+
self.assertIn('customtitle', restored.titles)
128+
self.assertIn('customprefix', restored.prefixes)
129+
self.assertNotIn('hon', restored.titles)
130+
# The contributing collections must match the original exactly.
131+
self.assertEqual(set(restored.titles), set(c.titles))
132+
self.assertEqual(set(restored.prefixes), set(c.prefixes))
133+
# The collections must also keep their manager type, not just contents.
134+
self.assertEqual(type(restored.titles), SetManager)
135+
self.assertEqual(type(restored.prefixes), SetManager)
136+
137+
def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None:
138+
"""An instance-level scalar override must survive a pickle round-trip."""
139+
c = Constants()
140+
c.empty_attribute_default = None
141+
142+
# Safe: round-tripping a Constants the test just built, not untrusted data.
143+
restored = pickle.loads(pickle.dumps(c))
144+
145+
self.assertEqual(restored.empty_attribute_default, None)
146+
147+
def test_pickle_roundtrip_preserves_regex_manager_subclass(self) -> None:
148+
"""regexes must round-trip as a RegexTupleManager, not a plain TupleManager.
149+
150+
TupleManager.__reduce__ previously hardcoded TupleManager, so the
151+
RegexTupleManager subclass was downgraded on unpickling. The difference
152+
is observable: RegexTupleManager returns the EMPTY_REGEX default for an
153+
unknown key, while a plain TupleManager returns None.
154+
"""
155+
c = Constants()
156+
157+
# Safe: round-tripping a Constants the test just built, not untrusted data.
158+
restored = pickle.loads(pickle.dumps(c))
159+
160+
self.assertEqual(type(restored.regexes), RegexTupleManager)
161+
self.assertEqual(restored.regexes.does_not_exist, EMPTY_REGEX)
162+
163+
def test_regexes_deepcopy_roundtrip(self) -> None:
164+
"""copy.deepcopy of a RegexTupleManager must round-trip.
165+
166+
__getattr__ answered every unknown name with the EMPTY_REGEX default,
167+
including the __deepcopy__ probe copy.deepcopy issues. copy then
168+
mistook that re.Pattern for a deep-copy hook and tried to call it.
169+
"""
170+
c = Constants()
171+
172+
dup = copy.deepcopy(c.regexes)
173+
174+
self.assertEqual(type(dup), RegexTupleManager)
175+
self.assertEqual(dict(dup), dict(c.regexes))
176+
# The EMPTY_REGEX default still applies to genuinely unknown keys.
177+
self.assertEqual(dup.does_not_exist, EMPTY_REGEX)
178+
179+
def test_regextuplemanager_ignores_dunder_lookups(self) -> None:
180+
"""Unknown dunder names report as absent, not as the EMPTY_REGEX default.
181+
182+
Dunder names are Python's protocol probes (copy.deepcopy looks up
183+
__deepcopy__, inspect.unwrap looks up __wrapped__, ...), never config
184+
keys. Answering them with a regex breaks that machinery.
185+
"""
186+
c = Constants()
187+
sentinel = object()
188+
189+
self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel)
190+
# A normal (non-dunder) unknown key still yields the EMPTY_REGEX default.
191+
self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX)
192+
193+
def test_tuplemanager_ignores_dunder_lookups(self) -> None:
194+
"""Base TupleManager must report unknown dunder names as absent too.
195+
196+
It returned None for any missing attribute, so `hasattr(tm, '__x__')`
197+
was always True — a landmine for any probe that does hasattr-then-call.
198+
Guarding dunders keeps the base consistent with RegexTupleManager.
199+
"""
200+
c = Constants()
201+
tm = c.capitalization_exceptions # a plain TupleManager
202+
sentinel = object()
203+
204+
self.assertEqual(type(tm), TupleManager)
205+
self.assertFalse(hasattr(tm, '__deepcopy__'))
206+
self.assertEqual(getattr(tm, '__wrapped__', sentinel), sentinel)
207+
# A normal (non-dunder) unknown key still returns the None default.
208+
self.assertEqual(tm.unknown_key, None)
209+
210+
def test_unpickle_legacy_state_with_property_key(self) -> None:
211+
"""Pickles written by older versions must still load.
212+
213+
The previous __getstate__ built its state from a dir() sweep, which
214+
always included the computed `suffixes_prefixes_titles` property (no
215+
customization required). That property has no setter, so __setstate__
216+
must skip such keys instead of raising AttributeError.
217+
218+
Covers the temporary migration shim in __setstate__; remove this test
219+
when that shim is dropped (a release or two after 1.2.1).
220+
"""
221+
c = Constants()
222+
c.titles.add('legacytitle')
223+
# Reproduce the legacy dir()-sweep state dict, which carries the
224+
# read-only `suffixes_prefixes_titles` property alongside the real config.
225+
legacy_state = {
226+
name: getattr(c, name) for name in dir(c) if not name.startswith('_')
227+
}
228+
self.assertIn('suffixes_prefixes_titles', legacy_state)
229+
230+
restored = Constants.__new__(Constants)
231+
restored.__setstate__(legacy_state)
232+
233+
# The real customization is recovered and the property key is ignored.
234+
self.assertIn('legacytitle', restored.titles)

tests/test_python_api.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import copy
2+
import pickle
13
import re
24

35
import pytest
@@ -45,6 +47,51 @@ def test_name_instance_pickle(self) -> None:
4547
hn = HumanName("Title First Middle Middle Last, Jr.")
4648
self.assertTrue(dill.pickles(hn))
4749

50+
def test_name_instance_pickle_preserves_instance_config(self) -> None:
51+
"""A HumanName carrying its own config must parse identically after a
52+
pickle round-trip.
53+
54+
HumanName pickles its instance Constants (``.C``) through the default
55+
__dict__ path, so a broken Constants round-trip silently produced a
56+
differently-configured parser on the other side.
57+
"""
58+
# Passing None as the second argument gives this name its own Constants.
59+
hn = HumanName("Smith, Dr. John", None)
60+
hn.C.titles.add('chancellor')
61+
hn.parse_full_name()
62+
63+
# Safe: round-tripping a HumanName the test just built, not untrusted data.
64+
restored = pickle.loads(pickle.dumps(hn))
65+
66+
self.assertIn('chancellor', restored.C.titles)
67+
restored.full_name = "Chancellor Jane Smith"
68+
self.assertEqual(restored.title, "Chancellor")
69+
70+
def test_name_instance_deepcopy(self) -> None:
71+
"""copy.deepcopy of a HumanName must round-trip.
72+
73+
HumanName has no custom copy hooks, so deepcopy recurses into its
74+
Constants (`.C`), and previously into `.C.regexes`, whose __getattr__
75+
answered copy's __deepcopy__ probe with a re.Pattern — making
76+
deepcopy of *any* HumanName raise TypeError.
77+
"""
78+
hn = HumanName("Dr. John P. Doe-Ray, CLU")
79+
80+
dup = copy.deepcopy(hn)
81+
82+
self.assertEqual(str(dup), str(hn))
83+
84+
def test_name_instance_deepcopy_isolates_instance_config(self) -> None:
85+
"""A deep-copied HumanName with its own config must be independent."""
86+
hn = HumanName("Smith, Dr. John", None)
87+
hn.C.titles.add('chancellor')
88+
89+
dup = copy.deepcopy(hn)
90+
dup.C.titles.add('marker')
91+
92+
self.assertIn('chancellor', dup.C.titles)
93+
self.assertNotIn('marker', hn.C.titles)
94+
4895
def test_comparison(self) -> None:
4996
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
5097
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")

0 commit comments

Comments
 (0)