-
Notifications
You must be signed in to change notification settings - Fork 112
Expand file tree
/
Copy pathtest_constants.py
More file actions
900 lines (763 loc) · 42.2 KB
/
Copy pathtest_constants.py
File metadata and controls
900 lines (763 loc) · 42.2 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
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
import copy
import pickle
import re
import timeit
from typing import Any
import pytest
from nameparser import HumanName
from nameparser.config import CONSTANTS, Constants, RegexTupleManager, SetManager, TupleManager
from nameparser.config.regexes import EMPTY_REGEX
from nameparser.config.titles import TITLES
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_constants_subclass_instance_is_used(self) -> None:
class CustomConstants(Constants):
pass
c = CustomConstants()
c.titles.add('chancellor')
hn = HumanName("Chancellor Jane Smith", constants=c)
self.assertIs(hn.C, c)
self.m(hn.title, "Chancellor", hn)
self.m(hn.first, "Jane", hn)
self.m(hn.last, "Smith", hn)
def test_constants_invalid_type_raises_typeerror(self) -> None:
with pytest.raises(TypeError, match="constants must be"):
HumanName("John Doe", constants="not a Constants") # type: ignore[arg-type]
def test_constants_class_instead_of_instance_raises_with_hint(self) -> None:
with pytest.raises(TypeError, match=r"did you mean Constants\(\)"):
HumanName("John Doe", constants=Constants) # type: ignore[arg-type]
def test_assigning_invalid_constants_after_construction_raises(self) -> None:
# #226 validated the constructor's `constants` argument, but `hn.C = ...`
# bypassed it entirely: the bad value was accepted silently and only
# surfaced far later, deep inside parsing, with no mention of `C` (#239)
hn = HumanName("John Doe")
with pytest.raises(TypeError, match="constants must be"):
hn.C = "garbage" # type: ignore[assignment]
def test_assigning_constants_class_after_construction_raises_with_hint(self) -> None:
hn = HumanName("John Doe")
with pytest.raises(TypeError, match=r"did you mean Constants\(\)"):
hn.C = Constants # type: ignore[assignment]
def test_assigning_none_to_constants_after_construction_builds_new_instance(self) -> None:
hn = HumanName("John Doe")
hn.C = None
self.assertIsNot(hn.C, CONSTANTS)
self.assertTrue(isinstance(hn.C, Constants))
def test_constants_bare_string_kwarg_raises_typeerror(self) -> None:
# a bare string is an iterable of its characters, so set('dr') would
# silently replace the default titles with {'d', 'r'} (#238); the
# type system can't catch this because str satisfies Iterable[str]
with pytest.raises(TypeError, match=r"wrap it in a list"):
Constants(titles='dr')
def test_set_manager_bare_string_raises_typeerror(self) -> None:
with pytest.raises(TypeError, match=r"wrap it in a list"):
SetManager('dr')
def test_set_manager_bytes_raises_with_decode_hint(self) -> None:
# a "wrap it in a list" hint would be a trap for bytes: [b'dr'] is
# accepted but its elements never match parsed str tokens
with pytest.raises(TypeError, match=r"decode it first"):
SetManager(b'dr') # type: ignore[arg-type]
def test_set_manager_bare_string_operand_raises_typeerror(self) -> None:
# Set's mixin __or__/__and__ hand _from_iterable a generator, so the
# constructor guard alone never sees a bare-string operand; without
# an operand check, c.titles |= 'esq' silently adds 'e', 's', 'q'
sm = SetManager(['dr', 'mr'])
for op in (lambda: sm | 'abc',
lambda: 'abc' | sm,
lambda: sm & 'abc',
lambda: 'abc' & sm,
lambda: sm - 'abc',
lambda: sm ^ 'abc'):
with pytest.raises(TypeError, match=r"wrap it in a list"):
op()
def test_set_manager_operators_accept_lists(self) -> None:
# end-to-end: an operator-built set wired back into Constants must
# behave like an add()-built one, normalization included
c = Constants()
with pytest.raises(TypeError, match=r"wrap it in a list"):
c.titles |= 'esq'
c.titles |= ['Esq.']
self.assertIn('esq', c.titles)
hn = HumanName("Esq Jane Smith", constants=c)
self.m(hn.title, "Esq", hn)
def test_set_manager_operators_normalize_like_add(self) -> None:
# add() lowercases and strips leading/trailing periods; without the
# same normalization of operator operands, (titles | ['Esq.']) keeps
# a raw 'Esq.', which the parser's lc()-based lookups can never match
# — silently broken config, same failure family as the bare-string
# shredding (#238)
sm = SetManager(['dr', 'mr'])
self.assertEqual((sm | ['Esq.']).elements, {'dr', 'mr', 'esq'})
self.assertEqual((['Esq.', 'Dr.'] | sm).elements, {'dr', 'mr', 'esq'})
self.assertEqual((sm & ['Dr.']).elements, {'dr'})
self.assertEqual((['Dr.'] & sm).elements, {'dr'})
self.assertEqual((sm - ['Dr.']).elements, {'mr'})
self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'})
self.assertEqual((sm ^ ['Dr.', 'Esq.']).elements, {'mr', 'esq'})
# pins __rxor__ separately in case it ever stops aliasing __xor__
self.assertEqual((['Dr.', 'Esq.'] ^ sm).elements, {'mr', 'esq'})
def test_set_manager_contains_normalizes_like_add(self) -> None:
# add()/remove()/the constructor/the operators all normalize (lowercase,
# strip leading/trailing periods) -- __contains__ was the lone holdout,
# so `'Dr.' in c.titles` returned False even though every other
# operation on the same value succeeded (#244)
sm = SetManager(['dr', 'mr'])
self.assertIn('Dr.', sm)
self.assertIn('MR', sm)
self.assertNotIn('Esq.', sm)
def test_set_manager_le_uses_normalizing_contains(self) -> None:
# the ABC comparison mixins route through __contains__, so an
# un-normalized membership check would leak into subset/equality too
sm = SetManager(['dr', 'mr'])
self.assertTrue(sm <= SetManager(['dr', 'mr', 'esq']))
self.assertTrue({'Dr.', 'Mr.'} <= sm)
def test_set_manager_rsub_is_order_sensitive(self) -> None:
# __sub__ and __rsub__ are hand-written separately (subtraction
# isn't commutative, unlike |/&/^), so a copy-paste operand swap
# in __rsub__ would silently flip the result and nothing else
# in this file would catch it
sm = SetManager(['dr', 'mr'])
self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'})
self.assertEqual((sm - ['Dr.', 'Esq.']).elements, {'mr'})
def test_set_manager_constructor_normalizes_like_add(self) -> None:
# without constructor normalization the operators misfire against
# the exact spelling visibly stored in the set: & returns empty
# and - silently no-ops
sm = SetManager(['Dr.', 'MR'])
self.assertEqual(sm.elements, {'dr', 'mr'})
self.assertEqual((sm & ['Dr.']).elements, {'dr'})
self.assertEqual((sm - ['Dr.']).elements, {'mr'})
def test_constants_kwarg_elements_are_normalized(self) -> None:
# Constants(titles=[...]) was the last silently-dead config path:
# a raw 'Chemistry' element can never match the parser's lc() lookups
c = Constants(titles=['chancellor', 'Chemistry'])
hn = HumanName("Chemistry Jane Smith", constants=c)
self.m(hn.title, "Chemistry", hn)
def test_default_constants_construction_does_not_alias_defaults(self) -> None:
# Constants() reuses the prebuilt _DEFAULT_TITLES snapshot via an
# identity check instead of re-validating ~1,400 entries; if that
# fast path ever returned the shared elements set instead of a
# copy, mutating one Constants() instance would corrupt every
# other instance's (and the module-level default's) titles
c1 = Constants()
c1.titles.add('zzz_should_not_leak')
c2 = Constants()
self.assertNotIn('zzz_should_not_leak', c2.titles)
self.assertNotIn('zzz_should_not_leak', TITLES)
def test_equal_but_not_identical_titles_list_still_validates(self) -> None:
# the fast path in Constants.__init__ is an `is` check against the
# raw TITLES object, not `==`; an equal-but-copied list must still
# go through full normalization rather than accidentally matching
c = Constants(titles=list(TITLES) + ['Extra.'])
self.assertIn('extra', c.titles)
def test_set_manager_non_str_elements_raise_typeerror(self) -> None:
# lc() on junk elements either crashes context-free (bytes, int) or
# silently transmutes None into '' — raise a curated error instead
with pytest.raises(TypeError, match=r"decode it first"):
SetManager([b'dr']) # type: ignore[list-item]
with pytest.raises(TypeError, match=r"expected str elements"):
SetManager([None]) # type: ignore[list-item]
with pytest.raises(TypeError, match=r"expected str elements"):
SetManager(['dr']) | [1] # type: ignore[list-item]
def test_tuplemanager_bare_string_raises_typeerror(self) -> None:
# dict(['ab', 'cd']) shreds each 2-char string into a key/value pair
# silently -- and dict('ab') itself raises a cryptic "dictionary update
# sequence element #0 has length 1; 2 is required" naming no argument
# and suggesting no fix (#242)
with pytest.raises(TypeError, match=r"wrap it in a list"):
TupleManager('ab') # type: ignore[arg-type]
def test_tuplemanager_bytes_raises_with_decode_hint(self) -> None:
with pytest.raises(TypeError, match=r"decode it first"):
TupleManager(b'ab') # type: ignore[arg-type]
def test_tuplemanager_string_element_raises_typeerror(self) -> None:
# the silent variant: an iterable of 2-character strings is a valid
# dict-update sequence, so each one shreds into a key/value pair
with pytest.raises(TypeError, match=r"key and a value"):
TupleManager(['ab', 'cd']) # type: ignore[arg-type]
def test_constants_capitalization_exceptions_string_elements_raise(self) -> None:
with pytest.raises(TypeError, match=r"key and a value"):
Constants(capitalization_exceptions=['ii']) # type: ignore[list-item]
def test_tuplemanager_accepts_mapping_and_pairs(self) -> None:
# the guard must not reject the two legitimate constructor shapes
tm = TupleManager({'a': '1'})
self.assertEqual(tm.a, '1')
tm2 = TupleManager([('b', '2')])
self.assertEqual(tm2.b, '2')
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_can_add_global_nickname_delimiter(self) -> None:
# https://github.com/derek73/python-nameparser/issues/112
hn = HumanName("")
hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}')
hn2 = HumanName("Benjamin {Ben} Franklin")
self.assertEqual(hn2.has_own_config, False)
self.m(hn2.nickname, "Ben", hn2)
# No manual cleanup needed: the autouse fixture in conftest.py snapshots
# and restores the global CONSTANTS collections (including
# nickname_delimiters) 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_clear_removes_all_entries(self) -> None:
hn = HumanName("Ms Hon Solo", constants=None)
hn.C.titles.clear()
hn.parse_full_name()
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
# empty_attribute_default has no explicit annotation (mypy infers str
# from the '' default), but None is documented/supported here -- see
# the doctest on the attribute's docstring in config/__init__.py.
# Not widened to str | None like string_format/suffix_delimiter
# because it cascades into ~8 public str-typed properties (title,
# first, middle, last, suffix, nickname, initials()).
CONSTANTS.empty_attribute_default = None # type: ignore[assignment]
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)
def test_empty_attribute_on_instance(self) -> None:
hn = HumanName("", None)
hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above
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 # type: ignore[assignment] # see test_empty_attribute_default above
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')
c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}')
# 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)
self.assertIn('curly_braces', restored.nickname_delimiters)
self.assertEqual(type(restored.nickname_delimiters), TupleManager)
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 # type: ignore[assignment] # see test_empty_attribute_default above
# 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_unpickle_legacy_state_with_property_key(self) -> None:
"""Pickles written by older versions must still load.
The previous __getstate__ built its state from a dir() sweep, which
always included the computed `suffixes_prefixes_titles` property (no
customization required). That property has no setter, so __setstate__
must skip such keys instead of raising AttributeError.
Covers the temporary migration shim in __setstate__; remove this test
when that shim is dropped (a release or two after 1.3.0).
"""
c = Constants()
c.titles.add('legacytitle')
# Reproduce the legacy dir()-sweep state dict, which carries the
# read-only `suffixes_prefixes_titles` property alongside the real config.
legacy_state = {
name: getattr(c, name) for name in dir(c) if not name.startswith('_')
}
self.assertIn('suffixes_prefixes_titles', legacy_state)
restored = Constants.__new__(Constants)
restored.__setstate__(legacy_state)
# The real customization is recovered and the property key is ignored.
self.assertIn('legacytitle', restored.titles)
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_nickname_delimiters_deepcopy_roundtrip(self) -> None:
"""copy.deepcopy of nickname_delimiters must round-trip.
Mirrors test_regexes_deepcopy_roundtrip: nickname_delimiters is a
plain TupleManager (not RegexTupleManager), but shares the same
__getattr__/__reduce__ machinery.
"""
c = Constants()
c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}')
dup = copy.deepcopy(c.nickname_delimiters)
self.assertEqual(type(dup), TupleManager)
self.assertEqual(dict(dup), dict(c.nickname_delimiters))
# Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are None.
self.assertIsNone(dup.does_not_exist)
self.assertIsNotNone(dup.curly_braces)
def test_maiden_delimiters_deepcopy_roundtrip(self) -> None:
"""copy.deepcopy of maiden_delimiters (empty by default) must round-trip."""
c = Constants()
dup = copy.deepcopy(c.maiden_delimiters)
self.assertEqual(type(dup), TupleManager)
self.assertEqual(dict(dup), {})
self.assertIsNone(dup.does_not_exist)
def test_nickname_delimiters_default_builtins_resolve_live(self) -> None:
# The three built-ins are stored as the *name* of a regexes entry
# (a plain string), not a copied pattern, so overriding
# self.C.regexes.parenthesis etc. keeps affecting nickname parsing --
# see test_overriding_builtin_regex_still_affects_nickname_parsing in
# test_nicknames.py.
c = Constants()
self.assertEqual(dict(c.nickname_delimiters), {
'quoted_word': 'quoted_word',
'double_quotes': 'double_quotes',
'parenthesis': 'parenthesis',
})
self.assertEqual(dict(c.maiden_delimiters), {})
def test_extra_nickname_delimiters_removed(self) -> None:
c = Constants()
self.assertFalse(hasattr(c, 'extra_nickname_delimiters'))
def test_tuplemanager_setattr_delattr_ignore_dunder_names(self) -> None:
"""Regression test for the bug that motivated nickname_delimiters'
__setattr__/__delattr__ dunder guard.
Constructing a subscripted generic, e.g.
TupleManager[re.Pattern[str] | str]({...}), makes typing's
GenericAlias.__call__ set __orig_class__ on the new instance right
after __init__ returns. Before the guard existed, __setattr__ was a
bare dict.__setitem__ alias, so that assignment silently inserted a
bogus '__orig_class__' entry into the dict itself, corrupting
.values()/iteration for every TupleManager instance -- exactly what
parse_nicknames() iterates over. This bit nickname_delimiters'
construction (#22) before the guard was added.
"""
tm = TupleManager[re.Pattern[str] | str]({'a': re.compile('x')})
self.assertNotIn('__orig_class__', tm)
self.assertEqual(dict(tm), {'a': re.compile('x')})
# Dunder assignment/deletion still work as normal object attributes,
# just routed around dict-backed storage.
tm.__custom_dunder__ = 'probe' # type: ignore[attr-defined]
self.assertEqual(tm.__custom_dunder__, 'probe') # type: ignore[attr-defined]
self.assertNotIn('__custom_dunder__', tm)
del tm.__custom_dunder__ # type: ignore[attr-defined]
self.assertFalse(hasattr(tm, '__custom_dunder__'))
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)
def test_tuplemanager_delattr_removes_dict_entry(self) -> None:
"""Deleting a non-dunder attribute must remove the dict entry.
TupleManager routes non-dunder attribute deletion to ``del self[attr]``
(the mirror of its dot-notation __setattr__), so ``del tm.key`` and
``del tm['key']`` are the same operation.
"""
c = Constants()
c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}')
self.assertIn('curly_braces', c.nickname_delimiters)
del c.nickname_delimiters.curly_braces # type: ignore[attr-defined]
self.assertNotIn('curly_braces', c.nickname_delimiters)
def test_assigning_non_setmanager_to_cached_union_member_raises(self) -> None:
"""A cached-union attribute rejects a plain iterable, demanding a SetManager.
The four ``_CachedUnionMember`` attributes wire an on-change callback into
the assigned manager; a bare list would silently break cache invalidation,
so __set__ fails loud with a message telling the caller to wrap it.
"""
c = Constants()
with pytest.raises(TypeError, match='SetManager'):
c.titles = ['mr', 'ms'] # type: ignore[assignment]
def test_assigning_non_setmanager_to_plain_set_attr_raises(self) -> None:
"""The five non-cached-union SetManager attributes reject bare assignment too.
Only the four ``_CachedUnionMember`` attributes were guarded; these five
were plain instance attributes, so ``c.conjunctions = 'and'`` silently
replaced the SetManager with a str, turning later ``in`` membership
checks into substring tests (#241).
"""
c = Constants()
for name in ('first_name_titles', 'conjunctions', 'bound_first_names',
'non_first_name_prefixes', 'suffix_acronyms_ambiguous'):
with pytest.raises(TypeError, match='SetManager'):
setattr(c, name, ['x'])
def test_bare_string_assignment_to_conjunctions_raises(self) -> None:
# the original #241 repro: 'and' assigned as a bare str silently
# degrades `piece.lower() in self.C.conjunctions` into a substring test
c = Constants()
with pytest.raises(TypeError, match='SetManager'):
c.conjunctions = 'and' # type: ignore[assignment]
def test_setstate_raises_on_missing_descriptor_field(self) -> None:
"""Unpickling a state blob missing a cached-union collection must fail loudly.
__setstate__ verifies every ``_CachedUnionMember``-backed attribute was
restored; a missing one would otherwise surface much later as an
AttributeError on the private mangled name (e.g. ``_titles``), which is
far harder to diagnose than a named ValueError here.
"""
c = Constants()
state = dict(c.__getstate__())
del state['titles']
restored = Constants.__new__(Constants)
with pytest.raises(ValueError, match='titles'):
restored.__setstate__(state)
class ParsingDoesNotMutateConfigTests(HumanNameTestBase):
"""Parsing a name must never write back into the Constants it reads.
The parser derives extra lookup entries while parsing (period-joined
titles/suffixes like "Lt.Gov.", conjunction-joined pieces like
"Mr. and Mrs." or "von und zu"). Those derivations are needed within the
parse, but historically they were add()ed to ``self.C`` — by default the
shared module-level CONSTANTS singleton — so parsing one name permanently
changed how every later name in the process parsed: parse results depended
on input order, and concurrent parsing raced on the shared sets.
"""
@staticmethod
def _config_snapshot(constants: Constants) -> dict:
"""Snapshot every piece of configuration ``constants`` owns.
Enumerated via ``Constants.__getstate__()`` — the canonical listing of
an instance's own config (descriptor-backed names mapped to their
public form, private caches like ``_pst`` excluded) — so a collection
added to ``Constants`` later is watched automatically, with no
attribute list to keep in sync.
"""
snap: dict[str, Any] = {}
for attr, value in constants.__getstate__().items():
if isinstance(value, SetManager):
snap[attr] = set(value)
elif isinstance(value, TupleManager):
snap[attr] = dict(value)
else:
snap[attr] = value # scalar override
# Fail loud if the structural discovery ever stops seeing the sets
# the parser historically leaked into.
assert {'titles', 'prefixes', 'conjunctions',
'suffix_acronyms', 'suffix_not_acronyms'} <= set(snap), \
"config snapshot no longer covers the historically-mutated sets"
return snap
def _assert_config_unchanged(self, constants: Constants, before: dict, parsed: str) -> None:
after = self._config_snapshot(constants)
diffs = []
for attr in sorted(set(before) | set(after)):
b, a = before.get(attr), after.get(attr)
if b != a:
if isinstance(b, set) and isinstance(a, set):
diffs.append(f"{attr}: added {sorted(a - b)}, removed {sorted(b - a)}")
else:
diffs.append(f"{attr}: {b!r} -> {a!r}")
self.assertEqual(diffs, [], f"parsing {parsed!r} changed the config: {diffs}")
def _assert_parse_leaves_config_unchanged(self, name: str) -> HumanName:
from nameparser.config import CONSTANTS
before = self._config_snapshot(CONSTANTS)
hn = HumanName(name)
self._assert_config_unchanged(CONSTANTS, before, name)
return hn
def test_period_joined_title_does_not_leak_into_titles(self) -> None:
hn = self._assert_parse_leaves_config_unchanged("Lt.Gov. John Doe")
# the within-parse derivation must still work
self.m(hn.title, "Lt.Gov.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Doe", hn)
def test_period_joined_suffix_does_not_leak_into_suffixes(self) -> None:
hn = self._assert_parse_leaves_config_unchanged("John Doe JD.CPA")
self.m(hn.first, "John", hn)
self.m(hn.last, "Doe", hn)
self.m(hn.suffix, "JD.CPA", hn)
def test_joined_conjunctions_do_not_leak_into_conjunctions(self) -> None:
hn = self._assert_parse_leaves_config_unchanged("Louis of the Netherlands")
self.m(hn.first, "Louis of the Netherlands", hn)
def test_title_conjunction_join_does_not_leak_into_titles(self) -> None:
hn = self._assert_parse_leaves_config_unchanged("Mr. and Mrs. John Smith")
self.m(hn.title, "Mr. and Mrs.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)
def test_prefix_conjunction_join_does_not_leak_into_prefixes(self) -> None:
hn = self._assert_parse_leaves_config_unchanged("Alois von und zu Liechtenstein")
self.m(hn.first, "Alois", hn)
self.m(hn.last, "von und zu Liechtenstein", hn)
def test_instance_owned_constants_not_mutated_by_parsing(self) -> None:
hn = HumanName("", constants=None)
before = self._config_snapshot(hn.C)
hn.full_name = "Lt.Gov. John Doe"
self._assert_config_unchanged(hn.C, before, "Lt.Gov. John Doe")
self.m(hn.title, "Lt.Gov.", hn)
def test_derivations_reset_between_parses_of_same_instance(self) -> None:
# Re-assigning full_name re-parses; each parse must re-derive from a
# clean slate and still resolve the period-joined title.
hn = HumanName("Lt.Gov. John Doe")
hn.full_name = "Lt.Gov. Jane Roe"
self.m(hn.title, "Lt.Gov.", hn)
self.m(hn.first, "Jane", hn)
self.m(hn.last, "Roe", hn)
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; with it, repeated access is
orders of magnitude faster. Rather than compare against a fixed
wall-clock budget (which flakes on slower/noisier CI runners), this
measures the uncached build cost on the same machine and asserts cached
access is much faster than that baseline.
"""
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"
# Baseline: cost of an uncached build, measured via fresh instances
# (each instance's first access rebuilds the union) on this machine.
m = 50
uncached_per_call = timeit.timeit(lambda: Constants().suffixes_prefixes_titles, number=m) / m
n = 10_000
cached_per_call = timeit.timeit(lambda: c.suffixes_prefixes_titles, number=n) / n
# If caching is broken, cached_per_call would be roughly the same as
# uncached_per_call. With caching intact it should be at least an
# order of magnitude faster.
limit = uncached_per_call / 10
assert cached_per_call < limit, (
f"suffixes_prefixes_titles appears uncached: cached access averaged "
f"{cached_per_call * 1e6:.1f} us/call vs an uncached build cost of "
f"{uncached_per_call * 1e6:.1f} us/call. Was _pst caching removed?"
)
class ConstantsReprTests(HumanNameTestBase):
def test_repr_reports_actual_collection_sizes(self) -> None:
c = Constants()
repr_str = repr(c)
for name in Constants._repr_collection_attrs:
self.assertIn(f"{name}: {len(getattr(c, name))}", repr_str)
def test_repr_omits_scalars_at_default_value(self) -> None:
c = Constants()
repr_str = repr(c)
for name in Constants._repr_scalar_attrs:
self.assertNotIn(name, repr_str)
def test_repr_shows_scalar_override_via_constructor(self) -> None:
c = Constants(middle_name_as_last=True)
self.assertIn("middle_name_as_last: True", repr(c))
def test_repr_shows_scalar_override_via_assignment(self) -> None:
# Most _repr_scalar_attrs (e.g. capitalize_name) aren't __init__ kwargs
# at all -- they're only ever overridden by direct assignment.
c = Constants()
c.capitalize_name = True
self.assertIn("capitalize_name: True", repr(c))
def test_repr_shows_multiple_simultaneous_scalar_overrides(self) -> None:
c = Constants(patronymic_name_order=True)
c.capitalize_name = True
repr_str = repr(c)
self.assertIn("patronymic_name_order: True", repr_str)
self.assertIn("capitalize_name: True", repr_str)
def test_repr_reflects_mutated_collection_size(self) -> None:
c = Constants()
before = len(c.titles)
c.titles.add('a-brand-new-title-for-repr-test')
self.assertIn(f"titles: {before + 1}", repr(c))
def test_repr_reports_empty_collection(self) -> None:
c = Constants(titles=[])
self.assertIn("titles: 0", repr(c))
def test_repr_is_bracketed_multiline(self) -> None:
repr_str = repr(Constants())
self.assertTrue(repr_str.startswith("<Constants : [\n"))
self.assertTrue(repr_str.endswith("\n]>"))