|
| 1 | +import copy |
| 2 | +import pickle |
| 3 | + |
1 | 4 | 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 |
3 | 7 |
|
4 | 8 | from tests.base import HumanNameTestBase |
5 | 9 |
|
@@ -104,3 +108,127 @@ def test_add_constant_with_explicit_encoding(self) -> None: |
104 | 108 | c = Constants() |
105 | 109 | c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') |
106 | 110 | 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) |
0 commit comments