-
Notifications
You must be signed in to change notification settings - Fork 112
Expand file tree
/
Copy pathtest_constants.py
More file actions
358 lines (300 loc) · 15.9 KB
/
Copy pathtest_constants.py
File metadata and controls
358 lines (300 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import copy
import pickle
import timeit
from nameparser import HumanName
from nameparser.config import Constants, RegexTupleManager, SetManager, TupleManager
from nameparser.config.regexes import EMPTY_REGEX
from tests.base import HumanNameTestBase
class ConstantsCustomizationTests(HumanNameTestBase):
def test_add_title(self) -> None:
hn = HumanName("Te Awanui-a-Rangi Black", constants=None)
start_len = len(hn.C.titles)
self.assertTrue(start_len > 0)
hn.C.titles.add('te')
self.assertEqual(start_len + 1, len(hn.C.titles))
hn.parse_full_name()
self.m(hn.title, "Te", hn)
self.m(hn.first, "Awanui-a-Rangi", hn)
self.m(hn.last, "Black", hn)
def test_remove_title(self) -> None:
hn = HumanName("Hon Solo", constants=None)
start_len = len(hn.C.titles)
self.assertTrue(start_len > 0)
hn.C.titles.remove('hon')
self.assertEqual(start_len - 1, len(hn.C.titles))
hn.parse_full_name()
self.m(hn.first, "Hon", hn)
self.m(hn.last, "Solo", hn)
def test_add_multiple_arguments(self) -> None:
hn = HumanName("Assoc Dean of Chemistry Robert Johns", constants=None)
hn.C.titles.add('dean', 'Chemistry')
hn.parse_full_name()
self.m(hn.title, "Assoc Dean of Chemistry", hn)
self.m(hn.first, "Robert", hn)
self.m(hn.last, "Johns", hn)
def test_instances_can_have_own_constants(self) -> None:
hn = HumanName("", None)
hn2 = HumanName("")
hn.C.titles.remove('hon')
self.assertEqual('hon' in hn.C.titles, False)
self.assertEqual(hn.has_own_config, True)
self.assertEqual('hon' in hn2.C.titles, True)
self.assertEqual(hn2.has_own_config, False)
def test_can_change_global_constants(self) -> None:
hn = HumanName("")
hn2 = HumanName("")
hn.C.titles.remove('hon')
self.assertEqual('hon' in hn.C.titles, False)
self.assertEqual('hon' in hn2.C.titles, False)
self.assertEqual(hn.has_own_config, False)
self.assertEqual(hn2.has_own_config, False)
# No manual cleanup needed: the autouse fixture in conftest.py snapshots
# and restores the global CONSTANTS collections around every test.
def test_remove_multiple_arguments(self) -> None:
hn = HumanName("Ms Hon Solo", constants=None)
hn.C.titles.remove('hon', 'ms')
hn.parse_full_name()
self.m(hn.first, "Ms", hn)
self.m(hn.middle, "Hon", hn)
self.m(hn.last, "Solo", hn)
def test_chain_multiple_arguments(self) -> None:
hn = HumanName("Dean Ms Hon Solo", constants=None)
hn.C.titles.remove('hon', 'ms').add('dean')
hn.parse_full_name()
self.m(hn.title, "Dean", hn)
self.m(hn.first, "Ms", hn)
self.m(hn.middle, "Hon", hn)
self.m(hn.last, "Solo", hn)
def test_empty_attribute_default(self) -> None:
from nameparser.config import CONSTANTS
_orig = CONSTANTS.empty_attribute_default
CONSTANTS.empty_attribute_default = None
hn = HumanName("")
self.m(hn.title, None, hn)
self.m(hn.first, None, hn)
self.m(hn.middle, None, hn)
self.m(hn.last, None, hn)
self.m(hn.suffix, None, hn)
self.m(hn.nickname, None, hn)
CONSTANTS.empty_attribute_default = _orig
def test_empty_attribute_on_instance(self) -> None:
hn = HumanName("", None)
hn.C.empty_attribute_default = None
self.m(hn.title, None, hn)
self.m(hn.first, None, hn)
self.m(hn.middle, None, hn)
self.m(hn.last, None, hn)
self.m(hn.suffix, None, hn)
self.m(hn.nickname, None, hn)
def test_none_empty_attribute_string_formatting(self) -> None:
hn = HumanName("", None)
hn.C.empty_attribute_default = None
self.assertEqual('', str(hn), hn)
def test_add_constant_with_explicit_encoding(self) -> None:
c = Constants()
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
self.assertIn('béck', c.titles)
def test_pickle_roundtrip_preserves_customizations(self) -> None:
"""A pickled Constants must restore its customized collections.
Regression test: __setstate__ previously passed the whole state dict
to __init__ as the `prefixes` argument, so every collection silently
reverted to its module default on unpickling.
"""
c = Constants()
c.titles.add('customtitle')
c.prefixes.add('customprefix')
c.titles.remove('hon')
# Safe: round-tripping a Constants the test just built, not untrusted data.
restored = pickle.loads(pickle.dumps(c))
self.assertIn('customtitle', restored.titles)
self.assertIn('customprefix', restored.prefixes)
self.assertNotIn('hon', restored.titles)
# The contributing collections must match the original exactly.
self.assertEqual(set(restored.titles), set(c.titles))
self.assertEqual(set(restored.prefixes), set(c.prefixes))
# The collections must also keep their manager type, not just contents.
self.assertEqual(type(restored.titles), SetManager)
self.assertEqual(type(restored.prefixes), SetManager)
def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None:
"""An instance-level scalar override must survive a pickle round-trip."""
c = Constants()
c.empty_attribute_default = None
# Safe: round-tripping a Constants the test just built, not untrusted data.
restored = pickle.loads(pickle.dumps(c))
self.assertEqual(restored.empty_attribute_default, None)
def test_pickle_roundtrip_preserves_regex_manager_subclass(self) -> None:
"""regexes must round-trip as a RegexTupleManager, not a plain TupleManager.
TupleManager.__reduce__ previously hardcoded TupleManager, so the
RegexTupleManager subclass was downgraded on unpickling. The difference
is observable: RegexTupleManager returns the EMPTY_REGEX default for an
unknown key, while a plain TupleManager returns None.
"""
c = Constants()
# Safe: round-tripping a Constants the test just built, not untrusted data.
restored = pickle.loads(pickle.dumps(c))
self.assertEqual(type(restored.regexes), RegexTupleManager)
self.assertEqual(restored.regexes.does_not_exist, EMPTY_REGEX)
def test_regexes_deepcopy_roundtrip(self) -> None:
"""copy.deepcopy of a RegexTupleManager must round-trip.
__getattr__ answered every unknown name with the EMPTY_REGEX default,
including the __deepcopy__ probe copy.deepcopy issues. copy then
mistook that re.Pattern for a deep-copy hook and tried to call it.
"""
c = Constants()
dup = copy.deepcopy(c.regexes)
self.assertEqual(type(dup), RegexTupleManager)
self.assertEqual(dict(dup), dict(c.regexes))
# The EMPTY_REGEX default still applies to genuinely unknown keys.
self.assertEqual(dup.does_not_exist, EMPTY_REGEX)
def test_regextuplemanager_ignores_dunder_lookups(self) -> None:
"""Unknown dunder names report as absent, not as the EMPTY_REGEX default.
Dunder names are Python's protocol probes (copy.deepcopy looks up
__deepcopy__, inspect.unwrap looks up __wrapped__, ...), never config
keys. Answering them with a regex breaks that machinery.
"""
c = Constants()
sentinel = object()
self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel)
# A normal (non-dunder) unknown key still yields the EMPTY_REGEX default.
self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX)
def test_tuplemanager_ignores_dunder_lookups(self) -> None:
"""Base TupleManager must report unknown dunder names as absent too.
It returned None for any missing attribute, so `hasattr(tm, '__x__')`
was always True — a landmine for any probe that does hasattr-then-call.
Guarding dunders keeps the base consistent with RegexTupleManager.
"""
c = Constants()
tm = c.capitalization_exceptions # a plain TupleManager
sentinel = object()
self.assertEqual(type(tm), TupleManager)
self.assertFalse(hasattr(tm, '__deepcopy__'))
self.assertEqual(getattr(tm, '__wrapped__', sentinel), sentinel)
# A normal (non-dunder) unknown key still returns the None default.
self.assertEqual(tm.unknown_key, None)
def test_suffixes_prefixes_titles_reflects_add_title(self) -> None:
"""suffixes_prefixes_titles must include titles added after construction."""
c = Constants()
_ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised
c.titles.add('emerita')
self.assertIn('emerita', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_add_prefix(self) -> None:
"""suffixes_prefixes_titles must include prefixes added after construction."""
c = Constants()
_ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised
c.prefixes.add('xpfx')
self.assertIn('xpfx', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_remove_title(self) -> None:
"""suffixes_prefixes_titles must not include a word that was only in titles and is then removed."""
c = Constants()
c.titles.add('emerita')
self.assertIn('emerita', c.suffixes_prefixes_titles)
c.titles.remove('emerita')
self.assertNotIn('emerita', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_remove_prefix(self) -> None:
"""suffixes_prefixes_titles must not include a word that was only in prefixes and is then removed."""
c = Constants()
c.prefixes.add('xpfx')
self.assertIn('xpfx', c.suffixes_prefixes_titles)
c.prefixes.remove('xpfx')
self.assertNotIn('xpfx', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_add_suffix_acronym(self) -> None:
"""suffixes_prefixes_titles must include suffix acronyms added after construction."""
c = Constants()
_ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised
c.suffix_acronyms.add('xsfx')
self.assertIn('xsfx', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_add_suffix_not_acronym(self) -> None:
"""suffixes_prefixes_titles must include non-acronym suffixes added after construction."""
c = Constants()
_ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised
c.suffix_not_acronyms.add('xsfx')
self.assertIn('xsfx', c.suffixes_prefixes_titles)
def test_pickle_roundtrip_rewires_invalidation_callbacks(self) -> None:
"""Mutations on a deserialized Constants must still invalidate the cache."""
c = Constants()
# Safe: round-tripping a Constants the test just built, not untrusted data.
restored = pickle.loads(pickle.dumps(c))
_ = restored.suffixes_prefixes_titles # prime the cache
restored.titles.add('posttitle')
self.assertIn('posttitle', restored.suffixes_prefixes_titles)
def test_is_rootname_consistent_with_is_title(self) -> None:
"""is_rootname must return False for words recognised by is_title."""
hn = HumanName("", constants=None)
_ = hn.C.suffixes_prefixes_titles # prime the cache so a stale entry would be observable
hn.C.titles.add('emerita')
self.assertFalse(hn.is_rootname('emerita'))
def test_is_rootname_consistent_with_is_prefix(self) -> None:
"""is_rootname must return False for words recognised by is_prefix."""
hn = HumanName("", constants=None)
_ = hn.C.suffixes_prefixes_titles # prime the cache so a stale entry would be observable
hn.C.prefixes.add('xpfx')
self.assertFalse(hn.is_rootname('xpfx'))
def test_suffixes_prefixes_titles_reflects_remove_suffix_acronym(self) -> None:
"""suffixes_prefixes_titles must reflect a suffix acronym removed after the cache is primed."""
c = Constants()
c.suffix_acronyms.add('xsfx')
self.assertIn('xsfx', c.suffixes_prefixes_titles) # primes the cache
c.suffix_acronyms.remove('xsfx')
self.assertNotIn('xsfx', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_remove_suffix_not_acronym(self) -> None:
"""suffixes_prefixes_titles must reflect a non-acronym suffix removed after the cache is primed."""
c = Constants()
c.suffix_not_acronyms.add('xsfx')
self.assertIn('xsfx', c.suffixes_prefixes_titles) # primes the cache
c.suffix_not_acronyms.remove('xsfx')
self.assertNotIn('xsfx', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_add_with_encoding(self) -> None:
"""add_with_encoding must invalidate the cache like add()/remove() do."""
c = Constants()
_ = c.suffixes_prefixes_titles # prime the cache
c.titles.add_with_encoding(b'b\351ck', encoding='latin_1')
self.assertIn('béck', c.suffixes_prefixes_titles)
def test_suffixes_prefixes_titles_reflects_replaced_manager(self) -> None:
"""Replacing a whole SetManager must invalidate the cache and wire the new manager.
Covers the config-teardown path where a fresh SetManager is assigned
directly (e.g. ``setattr(CONSTANTS, 'titles', SetManager(...))``).
"""
c = Constants()
_ = c.suffixes_prefixes_titles # prime the cache
c.titles = SetManager(['brandnewtitle'])
# The replacement is reflected immediately...
self.assertIn('brandnewtitle', c.suffixes_prefixes_titles)
# ...and the new manager's own mutations invalidate the cache too,
# proving the on_change callback was re-wired to the replacement.
_ = c.suffixes_prefixes_titles
c.titles.add('secondtitle')
self.assertIn('secondtitle', c.suffixes_prefixes_titles)
def test_replaced_manager_no_longer_invalidates_cache(self) -> None:
"""A SetManager detached by reassignment must not invalidate the new cache."""
c = Constants()
replaced = c.titles
c.titles = SetManager(['brandnewtitle'])
primed = c.suffixes_prefixes_titles
# Mutating the orphaned manager must leave the live cache untouched.
replaced.add('ghost')
self.assertIs(c.suffixes_prefixes_titles, primed)
self.assertNotIn('ghost', c.suffixes_prefixes_titles)
class SuffixesPrefixesTitlesPerformanceTests(HumanNameTestBase):
"""Guard against accidental cache removal on suffixes_prefixes_titles.
This library is commonly used to parse large batches of names, so
suffixes_prefixes_titles must remain cached. Without the cache, each call
rebuilds the union from ~700 strings (~50-100 µs); with it, repeated access
is ~1000x faster. This test asserts that 10 000 repeated calls complete
well within the time a single uncached union build would take.
"""
def test_repeated_access_is_cached(self) -> None:
c = Constants()
first = c.suffixes_prefixes_titles
second = c.suffixes_prefixes_titles
assert first is second, "suffixes_prefixes_titles should return the same cached object on repeated access"
n = 10_000
elapsed = timeit.timeit(lambda: c.suffixes_prefixes_titles, number=n)
# One uncached union build over ~700 strings takes ~50-100 µs on any
# modern machine. If caching is broken, 10 000 calls would take
# seconds; with caching they finish in well under 10 ms total.
limit = 0.010 # 10 ms = 1 µs/call average
assert elapsed < limit, (
f"suffixes_prefixes_titles appears uncached: {n} calls took "
f"{elapsed * 1000:.1f} ms (limit {limit * 1000:.0f} ms). "
"Was _pst caching removed?"
)