1"""v1 ``Constants`` compatibility shim over Lexicon/Policy
2(mechanisms.md#CONFIG-SHIM-SNAPSHOT). ``nameparser.config``
3re-exports these names from the swap
4commit onward; the whole module is deleted in 3.0 with the facade.
5
6Layering: facade layer -- may import anything public; here that's
7``nameparser.util`` for ``lc()``, ``nameparser.config.regexes`` for
8the read-only regexes proxy's underlying compiled patterns, and
9``nameparser._lexicon``/``_policy``/``_parser`` for the ``_snapshot()``
10translation and the shared parser cache.
11
12All ``nameparser.config.<data module>`` imports below are deferred
13(function-local), never module-level: ``nameparser/config/__init__.py``
14itself imports ``CONSTANTS``/``Constants``/etc. from this module, so a
15module-level ``from nameparser.config.regexes import REGEXES`` here
16would need the ``nameparser.config`` package's ``__init__.py`` to run
17to completion first -- which needs this module to already be fully
18initialized. Importing the data submodules lazily, on first use, breaks
19that cycle.
20"""
21from __future__ import annotations
22
23import functools
24import warnings
25from collections.abc import (
26 Callable, ItemsView, Iterable, Iterator, KeysView, Mapping, Set,
27 ValuesView,
28)
29from typing import NamedTuple, Self
30
31from nameparser._lexicon import Lexicon, _title_key
32from nameparser._parser import Parser
33from nameparser._policy import PatronymicRule, Policy
34from nameparser.util import lc
35
36
37#: The eight multi-word entries the pre-2.0 DEFAULT vocabulary shipped.
38#: Provably inert in every release (they can never match; see the
39#: 2.0.0 release log), so dropping them from a restored legacy pickle
40#: changes no parse -- and keeps the multi-word warning from firing
41#: eight times, with wrong advice, at library-internal lines, on the
42#: first parse after a supported 1.3/1.4 pickle upgrade.
43#:
44#: Gated on ALL EIGHT being present across the two fields -- the
45#: signature of a pre-2.0 blob, which froze the complete shipped set.
46#: A round-trip of 2.0-era state that carries FEWER than all eight
47#: keeps them (a user who deliberately re-added one or two does not
48#: match the signature, and .copy() never subtracts either). The trade:
49#: a 2.0 user who re-adds ALL eight exact strings is indistinguishable
50#: from a legacy blob and loses them on the next unpickle.
51_LEGACY_DEAD_ENTRIES = {
52 "titles": frozenset({"chargé d'affaires"}),
53 "suffix_acronyms": frozenset({
54 "leed ap", "nicet i", "nicet ii", "nicet iii", "nicet iv",
55 "psm i", "psm ii"}),
56}
57
58
59def _reject_bare_str_or_bytes(value: object, expected: str) -> None:
60 # A bare string is an iterable of its characters, so e.g. SetManager('dr')
61 # would silently shred it into {'d', 'r'} instead of raising -- shared by
62 # SetManager's constructor/operands (#238/#241) and TupleManager's
63 # constructor (#242). Ported from v1's `_reject_bare_str_or_bytes`.
64 if isinstance(value, bytes):
65 raise TypeError(
66 f"expected {expected}, got a single bytes; decode it first: "
67 f"{value!r}.decode()"
68 )
69 if isinstance(value, str):
70 raise TypeError(
71 f"expected {expected}, got a single str; wrap it in a list: "
72 f"[{value!r}]"
73 )
74
75
76def _lc_validated(s: object) -> str:
77 # Validates and lc()-normalizes a single element -- shared by bulk
78 # iterable normalization (constructor/operands) and add()'s per-string
79 # loop, so every path raises the same #238/#245-shaped TypeError instead
80 # of lc() crashing cryptically (bytes) or silently transmuting (None).
81 if isinstance(s, bytes):
82 raise TypeError(
83 f"expected a str, got bytes; decode it first: {s!r}.decode()"
84 )
85 if not isinstance(s, str):
86 raise TypeError(f"expected a str, got {type(s).__name__}: {s!r}")
87 return lc(s)
88
89
90def _normalize_iterable_of_strings(
91 elements: object, expected: str = "an iterable of strings") -> set[str]:
92 # a SetManager's elements were already validated/normalized when it was
93 # built, so copy them instead of re-validating (v1's fast path)
94 if isinstance(elements, SetManager):
95 return set(elements._elements)
96 _reject_bare_str_or_bytes(elements, expected)
97 return {_lc_validated(s) for s in elements} # type: ignore[attr-defined]
98
99
100class SetManager:
101 """v1 ``SetManager`` surface over a plain set of ``lc()``-normalized
102 strings. Mutations call ``_on_change`` (the owning Constants'
103 generation bump, wired by the facade). ``__call__`` and the
104 missing-member-tolerant ``remove()`` are gone per the #243 schedule
105 (warned 1.3.0, removed 2.0): ``remove()`` of a missing member raises
106 ``KeyError``, matching ``set.remove``.
107 """
108
109 _elements: set[str]
110 _on_change: Callable[[], None] | None
111
112 def __init__(self, elements: Iterable[str] = (),
113 _on_change: Callable[[], None] | None = None,
114 _field: str | None = None) -> None:
115 # _field carries a Constants field name (e.g. "titles") through to
116 # the bare-str/bytes guard's message, when this SetManager is being
117 # built on behalf of a named Constants field (constructor kwarg or
118 # __setattr__ auto-wrap); a direct SetManager(...) call gets the
119 # generic v1 message instead.
120 expected = f"{_field} to be an iterable of strings" if _field \
121 else "an iterable of strings"
122 self._elements = _normalize_iterable_of_strings(elements, expected)
123 self._on_change = _on_change
124
125 def _changed(self) -> None:
126 if self._on_change is not None:
127 self._on_change()
128
129 def add(self, *strings: str) -> SetManager:
130 """Add the normalized string arguments to the set. Returns
131 ``self`` for chaining."""
132 # notify only on real change (v1 parity): a no-op add must not
133 # bump the owner's generation
134 changed = False
135 try:
136 for s in strings:
137 normalized = _lc_validated(s) # TypeError on bytes (#245)
138 if normalized not in self._elements:
139 self._elements.add(normalized)
140 changed = True
141 finally:
142 # a TypeError mid-list still leaves earlier additions
143 # applied, so the owner must hear about them or its cache
144 # goes stale (same rule as remove() below)
145 if changed:
146 self._changed()
147 return self
148
149 def remove(self, *strings: str) -> SetManager:
150 """Remove the normalized string arguments from the set.
151 Raises ``KeyError`` if any argument is not a member. Returns
152 ``self`` for chaining."""
153 changed = False
154 try:
155 for s in strings:
156 # _lc_validated: bytes get the same decode-hint TypeError
157 # as add() (#245)
158 normalized = _lc_validated(s)
159 self._elements.remove(normalized) # KeyError on missing (#243)
160 changed = True
161 finally:
162 # a KeyError mid-list still leaves earlier removals applied,
163 # so the owner must hear about them or its cache goes stale
164 if changed:
165 self._changed()
166 return self
167
168 def discard(self, *strings: str) -> SetManager:
169 """Remove the normalized string arguments from the set if
170 present; missing members are ignored, like ``set.discard``.
171 Returns ``self`` for chaining."""
172 changed = False
173 for s in strings:
174 normalized = _lc_validated(s) # bytes decode hint (#245)
175 if normalized in self._elements:
176 self._elements.discard(normalized)
177 changed = True
178 if changed:
179 self._changed()
180 return self
181
182 def clear(self) -> SetManager:
183 """Remove all entries from the set. Returns ``self`` for
184 chaining."""
185 if self._elements:
186 self._elements.clear()
187 self._changed()
188 return self
189
190 def __contains__(self, item: object) -> bool:
191 return isinstance(item, str) and lc(item) in self._elements
192
193 def __iter__(self) -> Iterator[str]:
194 return iter(self._elements)
195
196 def __len__(self) -> int:
197 return len(self._elements)
198
199 def __eq__(self, other: object) -> bool:
200 if isinstance(other, SetManager):
201 return self._elements == other._elements
202 if isinstance(other, (set, frozenset)):
203 return self._elements == other
204 return NotImplemented
205
206 __hash__ = None # type: ignore[assignment] # mutable; v1 parity
207
208 # -- set operators: accept ANY iterable (v1.3 normalize-everywhere) -----
209 # A bare str/bytes operand raises TypeError via _normalize_iterable_of_
210 # strings rather than iterating its characters (#238/#241); everything
211 # else (list, generator, set, another SetManager, ...) is normalized
212 # through lc() before the plain set op runs.
213
214 def __or__(self, other: object) -> set[str]:
215 return self._elements | _normalize_iterable_of_strings(other)
216
217 __ror__ = __or__
218
219 def __and__(self, other: object) -> set[str]:
220 return self._elements & _normalize_iterable_of_strings(other)
221
222 __rand__ = __and__
223
224 def __sub__(self, other: object) -> set[str]:
225 return self._elements - _normalize_iterable_of_strings(other)
226
227 def __rsub__(self, other: object) -> set[str]:
228 return _normalize_iterable_of_strings(other) - self._elements
229
230 def __xor__(self, other: object) -> set[str]:
231 return self._elements ^ _normalize_iterable_of_strings(other)
232
233 __rxor__ = __xor__ # symmetric difference is commutative, like v1
234
235 # -- comparisons: v1 subclassed collections.abc.Set, whose __le__/__lt__/
236 # __ge__/__gt__ mixins only accept another Set-registered operand (set,
237 # frozenset, or another Set subclass) -- NOT an arbitrary iterable like
238 # list, unlike the operators above. Mirrored here since this SetManager
239 # doesn't itself subclass the ABC.
240
241 def __le__(self, other: object) -> bool:
242 if not isinstance(other, (SetManager, Set)):
243 return NotImplemented
244 if len(self) > len(other):
245 return False
246 return all(elem in other for elem in self)
247
248 def __lt__(self, other: object) -> bool:
249 if not isinstance(other, (SetManager, Set)):
250 return NotImplemented
251 return len(self) < len(other) and self.__le__(other)
252
253 def __ge__(self, other: object) -> bool:
254 if not isinstance(other, (SetManager, Set)):
255 return NotImplemented
256 if len(self) < len(other):
257 return False
258 return all(elem in self for elem in other)
259
260 def __gt__(self, other: object) -> bool:
261 if not isinstance(other, (SetManager, Set)):
262 return NotImplemented
263 return len(self) > len(other) and self.__ge__(other)
264
265 def __repr__(self) -> str:
266 # Sorted so repr is stable across runs -- set() iteration order
267 # depends on per-process string hash randomization.
268 elements = ", ".join(repr(e) for e in sorted(self._elements))
269 return f"SetManager({{{elements}}})" if self._elements else "SetManager(set())"
270
271 # -- pickle interop with v1 blobs ---------------------------------------
272
273 def __getstate__(self) -> dict[str, object]:
274 return {"_elements": set(self._elements)}
275
276 def __setstate__(self, state: dict[str, object]) -> None:
277 # v1 SetManager stored its set under `elements` (plain __dict__
278 # pickling); the shim stores `_elements`. Accept both, so a
279 # v1.3/1.4 Constants blob's embedded managers unpickle straight
280 # into shim instances. Re-normalize: nothing guarantees an
281 # incoming blob's elements passed through lc().
282 elements: Iterable[str] = state.get( # type: ignore[assignment]
283 "_elements", state.get("elements", ()))
284 self._elements = {lc(e) for e in elements}
285 self._on_change = None # rewired by the owning Constants
286
287
288def _validated_mapping_args(args: tuple[object, ...]) -> tuple[object, ...]:
289 """The #242 constructor guard, shared by TupleManager and
290 _DelimiterManager: a bare str/bytes silently shreds into a garbage
291 mapping (dict's own error), and an iterable of short strings
292 silently splits each one into a (key, value) pair -- ported from
293 v1's TupleManager.__init__ guard."""
294 if not args:
295 return args
296 arg = args[0]
297 _reject_bare_str_or_bytes(
298 arg, "a mapping or iterable of (key, value) pairs")
299 if not isinstance(arg, Mapping):
300 checked = []
301 for item in arg: # type: ignore[attr-defined]
302 if isinstance(item, (str, bytes)):
303 kind = "bytes" if isinstance(item, bytes) else "str"
304 raise TypeError(
305 f"expected (key, value) pairs, got a {kind} "
306 f"element {item!r}; a 2-character string "
307 "silently splits into a key and a value"
308 )
309 checked.append(item)
310 args = (checked, *args[1:])
311 return args
312
313
314class TupleManager(dict[str, object]):
315 """v1 ``TupleManager``: a dict with dot-notation access. Backs
316 ``capitalization_exceptions``. Unknown-key attribute access raises
317 ``AttributeError`` naming the key (#256, warned 1.4, enforced 2.0 --
318 the v1 ``DeprecationWarning`` is gone, this shim only speaks 2.0).
319 Mutations call ``_on_change`` (the owning Constants' generation
320 bump, wired by the facade).
321 """
322
323 _on_change: Callable[[], None] | None
324
325 def __init__(self, *args: object,
326 _on_change: Callable[[], None] | None = None,
327 **kwargs: object) -> None:
328 args = _validated_mapping_args(args)
329 super().__init__(*args, **kwargs)
330 self._on_change = _on_change
331
332 def _changed(self) -> None:
333 if self._on_change is not None:
334 self._on_change()
335
336 def __getattr__(self, name: str) -> object:
337 # Only reached for a missing attribute -- real instance attrs
338 # (_on_change) and dict methods (keys, get, ...) resolve first
339 # without ever hitting this. Dunder/underscore probes (pickling,
340 # copy.deepcopy, IPython's _repr_html_) are never config keys.
341 if name.startswith("_"):
342 raise AttributeError(name)
343 try:
344 return self[name]
345 except KeyError:
346 # #256: name the known keys, like v1's 1.4 deprecation warning
347 # did -- this shim only speaks 2.0, so what was a warning there
348 # is a hard AttributeError here.
349 raise AttributeError(
350 f"{name!r} is not a known key "
351 f"({', '.join(sorted(self))}); use .get() for intentional "
352 "soft access."
353 ) from None
354
355 def __setattr__(self, name: str, value: object) -> None:
356 # v1 parity: dunder probes (typing's __orig_class__, etc.) and this
357 # shim's own _on_change hook get real object-attribute storage;
358 # every other name -- including single-underscore ones, per v1 --
359 # routes to the dict so `t.mcdonald = 'x'` and `t['mcdonald'] = 'x'`
360 # are the same operation.
361 if name == "_on_change" or (name.startswith("__") and name.endswith("__")):
362 object.__setattr__(self, name, value)
363 else:
364 self[name] = value
365
366 def __delattr__(self, name: str) -> None:
367 if name == "_on_change" or (name.startswith("__") and name.endswith("__")):
368 object.__delattr__(self, name)
369 else:
370 del self[name]
371
372 def __setitem__(self, key: str, value: object) -> None:
373 super().__setitem__(key, value)
374 self._changed()
375
376 def __delitem__(self, key: str) -> None:
377 super().__delitem__(key)
378 self._changed()
379
380 def pop(self, *args: object) -> object:
381 # bump only on a real removal -- pop(key, default) on a missing
382 # key is a no-op read, not a mutation, same rule as SetManager's
383 # no-op add()
384 present = bool(args) and args[0] in self
385 result = super().pop(*args) # type: ignore[call-overload]
386 if present:
387 self._changed()
388 return result
389
390 def popitem(self) -> tuple[str, object]:
391 result = super().popitem() # KeyError when empty: no bump
392 self._changed()
393 return result
394
395 def clear(self) -> None:
396 had_items = bool(self)
397 super().clear()
398 if had_items: # clearing an empty dict is a no-op, not a change
399 self._changed()
400
401 def update(self, *args: object, **kwargs: object) -> None:
402 # dict.update's C path skips a subclass __setitem__; route every
403 # item through it so subclass validation (_DelimiterManager's
404 # sentinel rule) and the owner notification hold here too
405 for key, value in dict(*args, **kwargs).items():
406 self[key] = value
407
408 def setdefault(self, key: str, default: object = None) -> object:
409 if key in self:
410 return self[key] # existing key: a read, not a mutation
411 self[key] = default # validated + notifying path
412 return default
413
414 # in-place |= must validate/notify like update; dict's C path would
415 # skip both. mypy flags any non-overloaded __ior__ as inconsistent
416 # with dict.__or__'s overloads -- the runtime behavior is the plain
417 # dict |= contract, so the ignore is about typeshed shape only.
418 def __ior__(self, other: object) -> Self: # type: ignore[override, misc]
419 self.update(other)
420 return self
421
422 # -- pickle interop -------------------------------------------------
423
424 def __reduce__(self) -> tuple[type[TupleManager], tuple[()], dict[str, object]]:
425 return (type(self), (), dict(self))
426
427 def __setstate__(self, state: dict[str, object]) -> None:
428 # routes through __setitem__ (validated for _DelimiterManager);
429 # _on_change is still None here, so no spurious bumps
430 self.update(state)
431 self._on_change = None # rewired by the owning Constants
432
433
434#: The named delimiter buckets, translated to the ``Policy``
435#: (open, close) pairs they stand for. The first three are
436#: v1's; the rest are the #273 typographic conventions, named so the
437#: v1 keyed idioms (pop/move/del) work on them like the originals.
438#: Keep in sync with DEFAULT_NICKNAME_DELIMITERS in _policy.py (pinned
439#: by the default-Constants equality test).
440_SENTINEL_PAIRS = {
441 "quoted_word": ("'", "'"),
442 "double_quotes": ('"', '"'),
443 "parenthesis": ("(", ")"),
444 "smart_double_quotes": ("“", "”"),
445 "low_high_quotes": ("„", "“"),
446 "right_double_quotes": ("”", "”"),
447 "guillemets": ("«", "»"),
448 "reversed_guillemets": ("»", "«"),
449 "corner_brackets": ("「", "」"),
450 "white_corner_brackets": ("『", "』"),
451 "fullwidth_parenthesis": ("(", ")"),
452}
453
454#: derived, so the manager's accepted keys and _snapshot()'s
455#: translation table can never drift apart
456_DELIMITER_SENTINELS = tuple(_SENTINEL_PAIRS)
457
458
459class RegexTupleManager(TupleManager): # pickle-compat: do NOT delete
460 """Pickle-compat alias only: v1.4's ``Constants.regexes`` field was a
461 ``nameparser.config.RegexTupleManager`` instance (a ``TupleManager``
462 subclass whose ``__getattr__`` fell back to ``EMPTY_REGEX`` for an
463 unknown key). Unpickling a v1.4 blob resolves and constructs this
464 class -- via ``TupleManager.__reduce__``'s ``(type(self), (), state)``
465 -- before ``Constants.__setstate__`` runs, so the name must exist
466 here even though the shim's ``Constants._snapshot()`` never reads it:
467 ``regexes`` is a read-only ``_RegexesProxy`` in 2.0 (see above), and
468 ``Constants.__setstate__`` below deliberately ignores an incoming
469 ``regexes`` key rather than restoring it from this reconstructed
470 (and otherwise unused) instance.
471 """
472
473
474class _DelimiterManager(TupleManager):
475 """v1 ``nickname_delimiters``/``maiden_delimiters`` bucket. In 2.0
476 only the named sentinels in ``_DELIMITER_SENTINELS`` exist (the v1
477 trio plus the #273 typographic pairs) -- assigning any
478 other key raises so a caller reaches for a custom-delimiter Policy
479 kwarg instead of a dict entry that silently does nothing. ``pop()``/
480 ``__setitem__``/``__delitem__`` stay open (inherited) for the
481 documented bucket-move idiom, e.g.
482 ``maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')``.
483 """
484
485 def __init__(self, *args: object,
486 _on_change: Callable[[], None] | None = None,
487 **kwargs: object) -> None:
488 # dict's C-level __init__ never calls a subclass __setitem__, so
489 # collect and validate the initial items here -- BEFORE any item
490 # lands -- or the sentinel rule silently misses the constructor.
491 # The parent's #242 guard runs first: bare str/bytes gets the
492 # friendly TypeError, not dict's cryptic one.
493 args = _validated_mapping_args(args)
494 items: dict[str, object] = dict(*args, **kwargs)
495 for key in items:
496 self._reject_non_sentinel(key)
497 super().__init__(items, _on_change=_on_change)
498
499 @staticmethod
500 def _reject_non_sentinel(key: str) -> None:
501 if key not in _DELIMITER_SENTINELS:
502 raise TypeError(
503 f"2.0 delimiter managers accept only the named sentinels "
504 f"{_DELIMITER_SENTINELS}; for custom delimiter pairs use "
505 f"Policy(nickname_delimiters=...) / maiden_delimiters"
506 )
507
508 def __setitem__(self, key: str, value: object) -> None:
509 self._reject_non_sentinel(key)
510 super().__setitem__(key, value)
511 # update/setdefault/|= inherit TupleManager's routing through
512 # __setitem__, so they validate (and notify) for free
513
514
515class _RegexesProxy:
516 """Read-only view over the v1 compiled patterns
517 (``nameparser.config.regexes.REGEXES``). Reads keep working --
518 ``CONSTANTS.regexes.word`` stays informational -- but 2.0 configures
519 parsing behavior through named ``Policy`` flags, not by mutating a
520 regex, so any attribute *or* item assignment raises ``TypeError``
521 (the shim's uniform read-only rule).
522 """
523
524 @staticmethod
525 def _regexes() -> Mapping[str, object]:
526 # deferred import: see the module docstring's note on why every
527 # nameparser.config.<data module> import in this file is lazy
528 from nameparser.config.regexes import REGEXES
529 return REGEXES
530
531 #: dict methods this read-only proxy deliberately does not carry.
532 #: Without this, __getattr__ reports them as missing regexes and
533 #: sends the reader hunting for a vocabulary entry.
534 _DICT_ONLY = frozenset({
535 "pop", "popitem", "setdefault", "update", "clear", "fromkeys",
536 })
537
538 def __getattr__(self, name: str) -> object:
539 if name.startswith("_"):
540 raise AttributeError(name)
541 if name in _RegexesProxy._DICT_ONLY:
542 raise AttributeError(
543 f"{name!r} is not supported on CONSTANTS.regexes: it is a "
544 f"read-only view in 2.0, and parsing behavior is configured "
545 f"through named Policy flags"
546 )
547 try:
548 return self._regexes()[name]
549 except KeyError:
550 raise AttributeError(f"no regex named {name!r}") from None
551
552 def __getitem__(self, name: str) -> object:
553 return self._regexes()[name]
554
555 def __contains__(self, name: object) -> bool:
556 return name in self._regexes()
557
558 def __iter__(self) -> Iterator[str]:
559 return iter(self._regexes())
560
561 def keys(self) -> KeysView[str]:
562 return self._regexes().keys()
563
564 # Defined explicitly because __getattr__ would otherwise claim each
565 # of these names as a regex lookup and raise "no regex named
566 # 'items'". The sibling managers inherit the whole surface from
567 # dict; this proxy has to spell out the read half it supports.
568 # #256's deprecation text promised .get() on both managers.
569
570 def get(self, name: str, default: object = None) -> object:
571 return self._regexes().get(name, default)
572
573 def items(self) -> ItemsView[str, object]:
574 return self._regexes().items()
575
576 def values(self) -> ValuesView[object]:
577 return self._regexes().values()
578
579 def __len__(self) -> int:
580 return len(self._regexes())
581
582 def copy(self) -> dict[str, object]:
583 # v1's regexes was a dict subclass, so copy() returned a plain
584 # mutable dict. Mutating the copy never affected parsing there
585 # either, so this is a faithful read-only carry-over.
586 return dict(self._regexes())
587
588 def __setattr__(self, name: str, value: object) -> None:
589 self._raise_readonly(name)
590
591 def __setitem__(self, name: str, value: object) -> None:
592 self._raise_readonly(name)
593
594 @staticmethod
595 def _raise_readonly(name: str) -> None:
596 # bidi/emoji are the two regexes v1 code toggled directly
597 # (`CONSTANTS.regexes.bidi = False`) to opt out of stripping;
598 # point those two at their named Policy replacement, everything
599 # else gets the generic pointer.
600 hints = {
601 "bidi": "use Policy(strip_bidi=False) to keep bidi marks",
602 "emoji": "use Policy(strip_emoji=False) to keep emoji",
603 }
604 hint = hints.get(
605 name, "parsing behavior is configured through named Policy "
606 "flags in 2.0; if none fits, open an issue")
607 raise TypeError(
608 f"assigning CONSTANTS.regexes.{name} is not supported in "
609 f"2.0: {hint}"
610 )
611
612
613_SET_FIELDS = (
614 "prefixes", "suffix_acronyms", "suffix_not_acronyms",
615 "suffix_acronyms_ambiguous", "titles", "first_name_titles",
616 "conjunctions", "bound_first_names", "non_first_name_prefixes",
617)
618_MANAGER_FIELDS = _SET_FIELDS + (
619 "capitalization_exceptions", "nickname_delimiters", "maiden_delimiters",
620)
621
622#: v1's Constants.__repr__ field order (#221) -- kept as its own tuple
623#: rather than reusing _SET_FIELDS, whose order differs (v1 lists
624#: suffix_acronyms_ambiguous last, not fourth).
625_REPR_COLLECTION_ATTRS = (
626 "prefixes", "suffix_acronyms", "suffix_not_acronyms", "titles",
627 "first_name_titles", "conjunctions", "bound_first_names",
628 "non_first_name_prefixes", "suffix_acronyms_ambiguous",
629)
630#: v1's repr scalar order, minus empty_attribute_default -- removed in 2.0
631#: (#255), so there's no such attribute on this shim's Constants to show.
632_REPR_SCALAR_ATTRS = (
633 "string_format", "initials_format", "initials_delimiter",
634 "initials_separator", "suffix_delimiter",
635 "capitalize_name", "force_mixed_case_capitalization",
636 "patronymic_name_order", "middle_name_as_last",
637)
638
639_SCALAR_DEFAULTS: dict[str, object] = {
640 "patronymic_name_order": False,
641 "middle_name_as_last": False,
642 "capitalize_name": False,
643 "force_mixed_case_capitalization": False,
644 "string_format": "{title} {first} {middle} {last} {suffix} ({nickname})",
645 "initials_format": "{first} {middle} {last}",
646 "initials_delimiter": ".",
647 "initials_separator": " ",
648 "suffix_delimiter": None,
649}
650
651# distinguishes "attribute not set yet" from any real scalar value
652# (None is a legitimate value for string_format/suffix_delimiter)
653_UNSET = object()
654
655# distinguishes "kwarg not passed to Constants()" (use library defaults)
656# from any real value a caller might pass, including a falsy one like ""
657_UNSET_KWARG = object()
658
659
660_SHARED_MUTATION_MESSAGE = (
661 "mutating the shared CONSTANTS singleton is deprecated and will be "
662 "removed in 3.0; build a Lexicon/Policy (or a private Constants "
663 "passed as HumanName(constants=...)) instead. See the migration "
664 "guide."
665)
666
667
668def _default_vocab() -> dict[str, frozenset[str]]:
669 # v1 data modules stay the single vocabulary source through 2.x
670 # (same rule as Lexicon.default()).
671 from nameparser.config.bound_given_names import BOUND_GIVEN_NAMES
672 from nameparser.config.conjunctions import CONJUNCTIONS
673 from nameparser.config.particles import (
674 NON_GIVEN_NAME_PARTICLES, PARTICLES,
675 )
676 from nameparser.config.suffixes import (
677 SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_WORDS,
678 )
679 from nameparser.config.titles import GIVEN_NAME_TITLES, TITLES
680 return {
681 "prefixes": PARTICLES,
682 "suffix_acronyms": SUFFIX_ACRONYMS,
683 "suffix_not_acronyms": SUFFIX_WORDS,
684 "suffix_acronyms_ambiguous": SUFFIX_ACRONYMS_AMBIGUOUS,
685 "titles": TITLES,
686 "first_name_titles": GIVEN_NAME_TITLES,
687 "conjunctions": CONJUNCTIONS,
688 "bound_first_names": BOUND_GIVEN_NAMES,
689 "non_first_name_prefixes": NON_GIVEN_NAME_PARTICLES,
690 }
691
692
693class _RenderDefaults(NamedTuple):
694 """v1 scalar rendering knobs that have no home on ``Policy``
695 (mechanisms.md#CONFIG-SHIM-SNAPSHOT): ``__str__``/initials
696 formatting and capitalization stay
697 per-Constants defaults, layered onto a shared ``Parser`` by the
698 facade (nameparser/_facade.py) rather than folded into the cache
699 key."""
700
701 string_format: str | None
702 initials_format: str
703 initials_delimiter: str
704 initials_separator: str
705 suffix_delimiter: str | None
706 capitalize_name: bool
707 force_mixed_case_capitalization: bool
708
709
710@functools.lru_cache(maxsize=64)
711def _cached_parser(lexicon: Lexicon, policy: Policy) -> Parser:
712 # keyed on hashable value objects: shared across every facade whose
713 # Constants resolve to the same snapshot
714 # (mechanisms.md#CONFIG-SHIM-SNAPSHOT)
715 return Parser(lexicon=lexicon, policy=policy)
716
717
718class Constants:
719 """v1 ``Constants`` shim: a mutable container whose state resolves to
720 a frozen ``(Lexicon, Policy, _RenderDefaults)`` snapshot via
721 ``_snapshot()``. ``_generation`` increments on every mutation;
722 facades compare it against a cached value to decide whether their
723 snapshot is stale (dirty-tracking -- the facade side lives in
724 nameparser/_facade.py).
725
726 The module-level ``CONSTANTS`` singleton (below) has ``_shared``
727 flipped to ``True``: any mutation reached through it emits
728 ``DeprecationWarning`` pointing at ``Lexicon``/``Policy`` and
729 ``HumanName(constants=...)``. A private ``Constants()`` never
730 warns -- only the shared instance is on the 3.0 removal path.
731 """
732
733 _shared = False # the CONSTANTS singleton flips this to True
734 _generation: int
735
736 prefixes: SetManager
737 suffix_acronyms: SetManager
738 suffix_not_acronyms: SetManager
739 suffix_acronyms_ambiguous: SetManager
740 titles: SetManager
741 first_name_titles: SetManager
742 conjunctions: SetManager
743 bound_first_names: SetManager
744 non_first_name_prefixes: SetManager
745 capitalization_exceptions: TupleManager
746 nickname_delimiters: _DelimiterManager
747 maiden_delimiters: _DelimiterManager
748 regexes: _RegexesProxy
749
750 patronymic_name_order: bool
751 middle_name_as_last: bool
752 capitalize_name: bool
753 force_mixed_case_capitalization: bool
754 string_format: str | None
755 initials_format: str
756 initials_delimiter: str
757 initials_separator: str
758 suffix_delimiter: str | None
759
760 def __init__(
761 self,
762 *,
763 prefixes: Iterable[str] | object = _UNSET_KWARG,
764 suffix_acronyms: Iterable[str] | object = _UNSET_KWARG,
765 suffix_not_acronyms: Iterable[str] | object = _UNSET_KWARG,
766 suffix_acronyms_ambiguous: Iterable[str] | object = _UNSET_KWARG,
767 titles: Iterable[str] | object = _UNSET_KWARG,
768 first_name_titles: Iterable[str] | object = _UNSET_KWARG,
769 conjunctions: Iterable[str] | object = _UNSET_KWARG,
770 bound_first_names: Iterable[str] | object = _UNSET_KWARG,
771 non_first_name_prefixes: Iterable[str] | object = _UNSET_KWARG,
772 capitalization_exceptions:
773 Mapping[str, str] | Iterable[tuple[str, str]] | object
774 = _UNSET_KWARG,
775 regexes: object = _UNSET_KWARG,
776 patronymic_name_order: bool = False,
777 middle_name_as_last: bool = False,
778 ) -> None:
779 # v1.4 parity constructor kwargs (#238/#242/#244 hardening); the
780 # signature is spelled out rather than **kwargs so an unknown
781 # keyword raises Python's own TypeError with no help from here.
782 # `regexes` is the one deliberate 2.0 divergence: v1.4 accepted it
783 # (RegexTupleManager(regexes)), but constructor injection is
784 # assignment by another door, and __setattr__ above already
785 # forbids `c.regexes = ...` post-construction -- the uniform 2.0
786 # rule is that parsing behavior is configured through Policy, not
787 # by handing Constants a compiled-pattern mapping either way.
788 if regexes is not _UNSET_KWARG:
789 raise TypeError(
790 "Constants(regexes=...) is not supported in 2.0; parsing "
791 "behavior is configured through named Policy flags, not "
792 "by constructing Constants with a regex mapping. See the "
793 "migration guide."
794 )
795 overrides = {
796 "prefixes": prefixes,
797 "suffix_acronyms": suffix_acronyms,
798 "suffix_not_acronyms": suffix_not_acronyms,
799 "suffix_acronyms_ambiguous": suffix_acronyms_ambiguous,
800 "titles": titles,
801 "first_name_titles": first_name_titles,
802 "conjunctions": conjunctions,
803 "bound_first_names": bound_first_names,
804 "non_first_name_prefixes": non_first_name_prefixes,
805 }
806 vocab = _default_vocab()
807 object.__setattr__(self, "_generation", 0)
808 for name in _SET_FIELDS:
809 value = overrides[name]
810 if value is _UNSET_KWARG:
811 value = vocab[name]
812 # a caller-supplied value REPLACES that field's default
813 # vocabulary wholesale (v1 parity); SetManager itself validates/
814 # normalizes and rejects a bare str/bytes (#238/#241), naming
815 # this field in the message via _field=
816 object.__setattr__(
817 self, name,
818 SetManager(value, _on_change=self._bump, _field=name)) # type: ignore[arg-type]
819 if capitalization_exceptions is _UNSET_KWARG:
820 from nameparser.config.capitalization import (
821 CAPITALIZATION_EXCEPTIONS,
822 )
823 capitalization_exceptions = CAPITALIZATION_EXCEPTIONS
824 object.__setattr__(self, "capitalization_exceptions", TupleManager(
825 capitalization_exceptions, # type: ignore[arg-type]
826 _on_change=self._bump))
827 object.__setattr__(self, "nickname_delimiters", _DelimiterManager(
828 {name: name for name in _DELIMITER_SENTINELS},
829 _on_change=self._bump))
830 object.__setattr__(self, "maiden_delimiters", _DelimiterManager(
831 _on_change=self._bump))
832 object.__setattr__(self, "regexes", _RegexesProxy())
833 for name, value in _SCALAR_DEFAULTS.items():
834 object.__setattr__(self, name, value)
835 # the two behavior bools were v1.4 constructor kwargs too
836 # (docs/customize.rst doctests use them); truthiness matches v1,
837 # storage goes through the plain scalar slot
838 if patronymic_name_order:
839 object.__setattr__(self, "patronymic_name_order", True)
840 if middle_name_as_last:
841 object.__setattr__(self, "middle_name_as_last", True)
842
843 def _invalidate_pst(self) -> None:
844 """Pickle-compat alias only, never called at runtime: v1's four
845 cached-union ``SetManager`` fields (``prefixes``,
846 ``suffix_acronyms``, ``suffix_not_acronyms``, ``titles``) stored
847 their ``_on_change`` as the bound method
848 ``Constants._invalidate_pst``. A pickled bound method serializes
849 as a back-reference to its ``__self__`` (this same ``Constants``
850 instance, mid-unpickle) plus the method NAME -- reconstructed via
851 ``getattr(constants_obj, '_invalidate_pst')`` before
852 ``Constants.__setstate__`` ever runs (same two-phase-unpickling
853 story as ``RegexTupleManager`` above). Without this name, loading
854 a v1.4 blob raises ``AttributeError`` looking up the method.
855 ``SetManager.__setstate__`` (shim) never restores ``_on_change``
856 from pickled state regardless -- it always resets to ``None`` and
857 is rewired by ``Constants.__setstate__`` below -- so this bound
858 method value is reconstructed only to satisfy the pickle format;
859 it is discarded immediately and never invoked.
860 """
861
862 def _bump(self) -> None:
863 # stacklevel=3 is exact for the direct scalar-assignment path
864 # (user code -> Constants.__setattr__ -> here) and lands one
865 # frame short -- inside the manager's own add()/remove()/
866 # __setitem__ -- for the indirect manager-mutation path (user
867 # code -> manager method -> _changed() -> here), since a
868 # single warn() call can't be exact for both call depths at
869 # once. Either way the warning still fires from this module,
870 # not the manager's true caller, which is enough: only the
871 # DeprecationWarning's presence/category/message are load-
872 # bearing (see the specified test), not the reported line.
873 if self._shared:
874 warnings.warn(_SHARED_MUTATION_MESSAGE, DeprecationWarning,
875 stacklevel=3)
876 object.__setattr__(self, "_generation", self._generation + 1)
877
878 def __setattr__(self, name: str, value: object) -> None:
879 if name == "_shared":
880 # the flag the whole shared-mutation DeprecationWarning
881 # mechanism hinges on: only the module-level singleton flip
882 # (object.__setattr__ below) may set it
883 raise AttributeError(
884 "Constants._shared is read-only; it marks the module "
885 "singleton and is set once at import"
886 )
887 if name == "empty_attribute_default":
888 raise AttributeError(
889 "empty_attribute_default was removed in 2.0 (#255): "
890 "empty attributes are always ''"
891 )
892 if name == "regexes":
893 raise TypeError(
894 "replacing CONSTANTS.regexes is not supported in 2.0; "
895 "parsing behavior is configured through named Policy "
896 "flags"
897 )
898 if name in _SET_FIELDS:
899 # v1 allowed wholesale reassignment (c.titles = {...}); same
900 # bare-str/bytes guard as the constructor kwarg path (#238/#241)
901 value = SetManager(
902 value, _on_change=self._bump, _field=name) # type: ignore[arg-type]
903 elif name == "capitalization_exceptions":
904 value = TupleManager(value, _on_change=self._bump) # type: ignore[arg-type]
905 elif name in ("nickname_delimiters", "maiden_delimiters"):
906 value = _DelimiterManager(value, _on_change=self._bump) # type: ignore[arg-type]
907 elif name in _SCALAR_DEFAULTS and \
908 getattr(self, name, _UNSET) == value:
909 # no-op scalar assignment: managers already suppress no-op
910 # mutations, so re-assigning the current scalar value must
911 # not bump the generation (or warn on the shared singleton)
912 # either. __init__ writes via object.__setattr__, so this
913 # only runs on real user assignments -- _UNSET never
914 # actually matches, it just keeps a not-yet-set attribute
915 # from raising here. Manager-field reassignment above stays
916 # an unconditional bump: comparing manager contents isn't
917 # worth it.
918 object.__setattr__(self, name, value)
919 return
920 object.__setattr__(self, name, value)
921 if name in _MANAGER_FIELDS or name in _SCALAR_DEFAULTS:
922 self._bump()
923
924 def copy(self) -> Constants:
925 """Independent copy (#260), subclass-preserving. Divergence from
926 v1 (which deepcopied __dict__): attributes OUTSIDE the known
927 field surface -- e.g. ad-hoc names stashed on the instance --
928 are not carried by copy() or pickling; only the enumerated
929 fields survive.""" # #260
930 # An independent instance with its own generation counter and
931 # its own manager callbacks -- not a shared-state alias like a
932 # naive attribute-for-attribute copy would produce. v1's copy()
933 # was `copy.deepcopy(self)`, which builds the new object via
934 # `type(self).__new__(type(self))` -- NOT by calling `type(self)()`
935 # -- so a Constants subclass copies as itself without its __init__
936 # running (and without needing to satisfy whatever signature that
937 # __init__ might require). Mirrored here with an explicit __new__
938 # bypass rather than type(self)().
939 new = object.__new__(type(self))
940 object.__setattr__(new, "_generation", 0)
941 for name in _SET_FIELDS:
942 object.__setattr__(
943 new, name,
944 SetManager(getattr(self, name), _on_change=new._bump))
945 object.__setattr__(new, "capitalization_exceptions", TupleManager(
946 dict(self.capitalization_exceptions), _on_change=new._bump))
947 for bucket in ("nickname_delimiters", "maiden_delimiters"):
948 object.__setattr__(new, bucket, _DelimiterManager(
949 dict(getattr(self, bucket)), _on_change=new._bump))
950 object.__setattr__(new, "regexes", _RegexesProxy())
951 for name in _SCALAR_DEFAULTS:
952 object.__setattr__(new, name, getattr(self, name))
953 return new
954
955 def __repr__(self) -> str: # #221
956 # Collections (some with hundreds of entries, e.g. titles/prefixes)
957 # are summarized as counts rather than dumped in full, like v1.
958 # Scalars are only shown when they differ from the library default
959 # -- _SCALAR_DEFAULTS stands in for v1's `getattr(type(self), name)`
960 # class-level default, since this shim's scalar defaults are
961 # instance attributes set in __init__, not class attributes.
962 lines = [f" {name}: {len(getattr(self, name))}"
963 for name in _REPR_COLLECTION_ATTRS]
964 lines += [
965 f" {name}: {value!r}" for name in _REPR_SCALAR_ATTRS
966 if (value := getattr(self, name)) != _SCALAR_DEFAULTS[name]
967 ]
968 return "<Constants : [\n" + "\n".join(lines) + "\n]>"
969
970 # -- snapshot -----------------------------------------------------------
971
972 def _snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
973 # generation-keyed cache: rebuilding the Lexicon re-normalizes
974 # ~1400 vocabulary entries (~185us) -- the same dirty-tracking
975 # the facade uses, applied one level up. A pure read either way
976 # (no bump, no warning).
977 cached = getattr(self, "_snapshot_cache", None)
978 if cached is not None and cached[0] == self._generation:
979 return cached[1] # type: ignore[no-any-return]
980 snapshot = self._build_snapshot()
981 object.__setattr__(
982 self, "_snapshot_cache", (self._generation, snapshot))
983 return snapshot
984
985 def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
986 """Resolve this v1-shaped, mutable Constants into the frozen
987 2.0 value objects it corresponds to. A pure read: no
988 generation bump, no deprecation warning even on the shared
989 singleton -- only direct attribute mutation is on the 3.0
990 removal path.
991 """
992 from nameparser.config.conjunctions import CONJUNCTIONS_AMBIGUOUS
993 from nameparser.config.maiden_markers import MAIDEN_MARKERS
994 from nameparser.config.suffixes import GLUED_HONORIFICS
995 from nameparser.config.surnames import KOREAN_SURNAMES
996 acronyms = frozenset(self.suffix_acronyms)
997 particles = frozenset(self.prefixes)
998 conjunctions = frozenset(self.conjunctions)
999 bound = frozenset(self.bound_first_names)
1000 ambiguous_acronyms = frozenset(self.suffix_acronyms_ambiguous) & acronyms
1001 # Drop any ambiguous acronym from the word set rather than the
1002 # other way round. Lexicon forbids the overlap because the word
1003 # branch bypasses the period gate, and adding an ambiguous
1004 # acronym to suffix_not_acronyms is INERT in v1 anyway:
1005 # is_suffix already accepts it via the acronym branch, and
1006 # reserve_last keeps it as the surname. So ignoring the
1007 # addition reproduces v1 ("Jack Ma" keeps last='Ma'), where
1008 # dropping it from the AMBIGUOUS set instead ungated the word
1009 # and lost the family name -- a silent misparse worse than the
1010 # raise it avoided.
1011 suffix_words = frozenset(self.suffix_not_acronyms) - ambiguous_acronyms
1012 # keep in sync with _lexicon._default_lexicon() (pinned by
1013 # tests/v2/test_config_shim.py::test_snapshot_field_translation)
1014 lexicon = Lexicon(
1015 titles=frozenset(self.titles),
1016 # TRANSLATE, do not filter. The two versions build the same
1017 # lookup key differently: v1 joins the raw title run and
1018 # then applies lc(), which strips only the whole string's
1019 # edge periods, so an interior word keeps its own ("lt.
1020 # col"). v2 normalizes each token and then joins ("lt col").
1021 # Re-folding per word converts a v1 entry into the v2
1022 # spelling; filtering instead dropped every multi-word
1023 # honorific containing an abbreviation or a conjunction and
1024 # silently swapped given and family.
1025 # Only entries v1 could actually match: its key is the
1026 # joined title run, so always single-spaced and never
1027 # empty. An entry holding a whitespace run was inert there
1028 # (translating it would start matching), and one that folds
1029 # away entirely would trip _normset's empty-entry check on
1030 # a config v1 simply ignored.
1031 given_name_titles=frozenset(
1032 t for t in (
1033 _title_key(e.split())
1034 for e in self.first_name_titles
1035 if e == " ".join(e.split())
1036 ) if t),
1037 suffix_acronyms=acronyms,
1038 suffix_words=suffix_words,
1039 # Intersect with acronyms: Lexicon enforces ambiguous <=
1040 # acronyms; v1 behaves the same when an acronym is deleted
1041 # but its ambiguous entry lingers (the entry stops
1042 # mattering).
1043 suffix_acronyms_ambiguous=ambiguous_acronyms,
1044 particles=particles,
1045 # complement translation: v1 marks the never-given subset;
1046 # v2 marks the may-be-given subset. The trailing union keeps
1047 # a config v1 accepted: particles.py asserts its own data has
1048 # no word in both NON_GIVEN_NAME_PARTICLES and
1049 # BOUND_GIVEN_NAMES, but nothing stops a caller adding one at
1050 # runtime, and v1 then lets the bound rule win (leading "dos
1051 # Santos Silva" parses first="dos Santos"). Treating such a
1052 # word as may-be-given reproduces that rather than raising.
1053 #
1054 # KNOWN DEVIATION, pinned by
1055 # test_bound_never_given_prefix_deviates_on_two_pieces: v1's
1056 # join has a reserve_last guard, so with only two pieces it
1057 # does NOT fire and the word stays never-given ("dos Santos"
1058 # -> last="dos Santos"). Promotion here is unconditional, so
1059 # that case reads given="dos", family="Santos". v1's rule is
1060 # piece-count dependent and a static vocabulary set cannot
1061 # express it; the alternative is raising on a config v1
1062 # accepted, which is worse. Only reachable via a runtime
1063 # config the shipped data forbids.
1064 particles_ambiguous=(
1065 particles - frozenset(self.non_first_name_prefixes))
1066 | (bound & particles),
1067 conjunctions=conjunctions,
1068 # no v1 manager of its own: the ambiguous-connective
1069 # subset is 2.4 behavior (#383/#479), so it rides in the
1070 # snapshot only. Lexicon does NOT check this pair -- unlike
1071 # honorific_tails against suffix_words below -- so the
1072 # intersection is a provable no-op, kept only for
1073 # `_snapshot() == Lexicon.default()` legibility; the v1
1074 # knob (deleting the conjunction) turns the marking off
1075 # through the fork's own base-vocabulary test rather than
1076 # through this intersection.
1077 conjunctions_ambiguous=CONJUNCTIONS_AMBIGUOUS & conjunctions,
1078 bound_given_names=bound,
1079 # v1 Constants has no manager for these (#274 is 2.0
1080 # behavior); the data module is the only source
1081 maiden_markers=MAIDEN_MARKERS,
1082 # likewise no v1 manager: the unspaced-name segmentation
1083 # vocabulary is 2.0 behavior (#271), so it rides in the
1084 # snapshot only -- v1's Constants surface stays frozen.
1085 surnames=KOREAN_SURNAMES,
1086 # likewise no v1 manager: the glued-honorific tail set is
1087 # 2.1 behavior (#308), so it rides in the snapshot only.
1088 # Intersect with the word set: Lexicon enforces tails <=
1089 # suffix_words, and v1 semantics are that deleting a suffix
1090 # word turns the behavior off -- a lingering tail simply
1091 # stops mattering, the same rule ambiguous_acronyms gets
1092 # against suffix_acronyms above.
1093 honorific_tails=GLUED_HONORIFICS & suffix_words,
1094 # TupleManager is dict[str, object] (v1 parity: values were
1095 # never statically str-typed); every real entry is a str,
1096 # same assumption _DelimiterManager's sentinel lookup makes
1097 capitalization_exceptions=tuple(
1098 sorted(self.capitalization_exceptions.items())), # type: ignore[arg-type]
1099 )
1100 rules = frozenset({PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) \
1101 if self.patronymic_name_order else frozenset()
1102 policy = Policy(
1103 patronymic_rules=rules,
1104 middle_as_family=self.middle_name_as_last,
1105 nickname_delimiters=frozenset(
1106 _SENTINEL_PAIRS[k] for k in self.nickname_delimiters),
1107 # v1 precedence: a pair in BOTH v1 buckets parses as nickname.
1108 # Policy itself resolves overlap the other way (maiden wins),
1109 # so pre-subtract here to keep the facade at v1 behavior.
1110 maiden_delimiters=frozenset(
1111 _SENTINEL_PAIRS[k] for k in self.maiden_delimiters
1112 if k not in self.nickname_delimiters),
1113 # suffix_delimiter is a _RenderDefaults-only field here; the
1114 # facade layers it onto extra_suffix_delimiters per
1115 # instance -- _snapshot() itself stays pure translation
1116 )
1117 defaults = _RenderDefaults(
1118 self.string_format, self.initials_format, self.initials_delimiter,
1119 self.initials_separator, self.suffix_delimiter,
1120 self.capitalize_name, self.force_mixed_case_capitalization)
1121 return lexicon, policy, defaults
1122
1123 # -- pickle -----------------------------------------------------------
1124
1125 def __getstate__(self) -> dict[str, object]:
1126 state: dict[str, object] = {}
1127 for name in _SET_FIELDS:
1128 state[name] = set(getattr(self, name))
1129 state["capitalization_exceptions"] = dict(
1130 self.capitalization_exceptions)
1131 state["nickname_delimiters"] = dict(self.nickname_delimiters)
1132 state["maiden_delimiters"] = dict(self.maiden_delimiters)
1133 for name in _SCALAR_DEFAULTS:
1134 state[name] = getattr(self, name)
1135 return state
1136
1137 def __setstate__(self, state: dict[str, object]) -> None:
1138 if "suffixes_prefixes_titles" in state:
1139 # pre-1.3.0 blob: its dir()-sweep __getstate__ captured this
1140 # computed property. The 1.4 DeprecationWarning promised
1141 # ValueError in 2.0 (#279).
1142 raise ValueError(
1143 "this pickle was written by nameparser <= 1.2.x (#279); "
1144 "re-pickle under 1.3/1.4 to migrate, or re-create the "
1145 "configuration. See "
1146 "https://github.com/derek73/python-nameparser/issues/279"
1147 )
1148 # Accepts BOTH shapes with a single overlay: the shim's own
1149 # state and v1.3/1.4 state (public field names -> manager/
1150 # scalar values) share every key that matters, so no shape
1151 # marker is needed. empty_attribute_default is accepted and
1152 # DROPPED (#255: empty is always '' in 2.0).
1153 state = {k: v for k, v in state.items()
1154 if k != "empty_attribute_default"}
1155 self.__init__() # type: ignore[misc] # defaults, then overlay
1156 # (managers re-wrapped below so _on_change points at THIS
1157 # instance, not whatever produced the incoming state)
1158 managers: dict[str, SetManager] = {}
1159 for name in _SET_FIELDS:
1160 if name in state:
1161 managers[name] = SetManager(
1162 state[name], _on_change=self._bump) # type: ignore[arg-type]
1163 # SetManager normalized on construction, so the frozen 1.3/1.4
1164 # vocabulary's dead entries are matchable in their normalized
1165 # spelling here. Subtract only when ALL EIGHT are present --
1166 # the pre-2.0 signature; a 2.0 user who re-added one or two
1167 # keeps them through a round-trip (see _LEGACY_DEAD_ENTRIES).
1168 legacy = all(
1169 name in managers and entry in managers[name]
1170 for name, entries in _LEGACY_DEAD_ENTRIES.items()
1171 for entry in entries
1172 )
1173 for name, manager in managers.items():
1174 if legacy:
1175 # Reach past the public discard() deliberately: this is
1176 # part of restoring the state, not a mutation of it, and
1177 # must not bump the generation of an instance that is
1178 # still being built.
1179 manager._elements -= _LEGACY_DEAD_ENTRIES.get(
1180 name, frozenset())
1181 object.__setattr__(self, name, manager)
1182 if "capitalization_exceptions" in state:
1183 object.__setattr__(
1184 self, "capitalization_exceptions", TupleManager(
1185 state["capitalization_exceptions"], # type: ignore[arg-type]
1186 _on_change=self._bump))
1187 for bucket in ("nickname_delimiters", "maiden_delimiters"):
1188 if bucket in state:
1189 object.__setattr__(self, bucket, _DelimiterManager(
1190 state[bucket], _on_change=self._bump)) # type: ignore[arg-type]
1191 for name in _SCALAR_DEFAULTS:
1192 if name in state:
1193 object.__setattr__(self, name, state[name])
1194
1195
1196CONSTANTS = Constants()
1197object.__setattr__(CONSTANTS, "_shared", True)