Source code for nameparser._config_shim

"""v1 ``Constants`` compatibility shim over Lexicon/Policy (migration
spec §3). ``nameparser.config`` re-exports these names from the swap
commit onward; the whole module is deleted in 3.0 with the facade.

Layering: facade layer -- may import anything public; here that's
``nameparser.util`` for ``lc()``, ``nameparser.config.regexes`` for
the read-only regexes proxy's underlying compiled patterns, and
``nameparser._lexicon``/``_policy``/``_parser`` for the ``_snapshot()``
translation and the shared parser cache.

All ``nameparser.config.<data module>`` imports below are deferred
(function-local), never module-level: ``nameparser/config/__init__.py``
itself imports ``CONSTANTS``/``Constants``/etc. from this module, so a
module-level ``from nameparser.config.regexes import REGEXES`` here
would need the ``nameparser.config`` package's ``__init__.py`` to run
to completion first -- which needs this module to already be fully
initialized. Importing the data submodules lazily, on first use, breaks
that cycle.
"""
from __future__ import annotations

import functools
import warnings
from collections.abc import (
    Callable, ItemsView, Iterable, Iterator, KeysView, Mapping, Set,
    ValuesView,
)
from typing import NamedTuple, Self

from nameparser._lexicon import Lexicon, _title_key
from nameparser._parser import Parser
from nameparser._policy import PatronymicRule, Policy
from nameparser.util import lc


def _reject_bare_str_or_bytes(value: object, expected: str) -> None:
    # A bare string is an iterable of its characters, so e.g. SetManager('dr')
    # would silently shred it into {'d', 'r'} instead of raising -- shared by
    # SetManager's constructor/operands (#238/#241) and TupleManager's
    # constructor (#242). Ported from v1's `_reject_bare_str_or_bytes`.
    if isinstance(value, bytes):
        raise TypeError(
            f"expected {expected}, got a single bytes; decode it first: "
            f"{value!r}.decode()"
        )
    if isinstance(value, str):
        raise TypeError(
            f"expected {expected}, got a single str; wrap it in a list: "
            f"[{value!r}]"
        )


def _lc_validated(s: object) -> str:
    # Validates and lc()-normalizes a single element -- shared by bulk
    # iterable normalization (constructor/operands) and add()'s per-string
    # loop, so every path raises the same #238/#245-shaped TypeError instead
    # of lc() crashing cryptically (bytes) or silently transmuting (None).
    if isinstance(s, bytes):
        raise TypeError(
            f"expected a str, got bytes; decode it first: {s!r}.decode()"
        )
    if not isinstance(s, str):
        raise TypeError(f"expected a str, got {type(s).__name__}: {s!r}")
    return lc(s)


def _normalize_iterable_of_strings(
        elements: object, expected: str = "an iterable of strings") -> set[str]:
    # a SetManager's elements were already validated/normalized when it was
    # built, so copy them instead of re-validating (v1's fast path)
    if isinstance(elements, SetManager):
        return set(elements._elements)
    _reject_bare_str_or_bytes(elements, expected)
    return {_lc_validated(s) for s in elements}  # type: ignore[attr-defined]


[docs] class SetManager: """v1 ``SetManager`` surface over a plain set of ``lc()``-normalized strings. Mutations call ``_on_change`` (the owning Constants' generation bump, wired by a later task). ``__call__`` and the missing-member-tolerant ``remove()`` are gone per the #243 schedule (warned 1.3.0, removed 2.0): ``remove()`` of a missing member raises ``KeyError``, matching ``set.remove``. """ _elements: set[str] _on_change: Callable[[], None] | None def __init__(self, elements: Iterable[str] = (), _on_change: Callable[[], None] | None = None, _field: str | None = None) -> None: # _field carries a Constants field name (e.g. "titles") through to # the bare-str/bytes guard's message, when this SetManager is being # built on behalf of a named Constants field (constructor kwarg or # __setattr__ auto-wrap); a direct SetManager(...) call gets the # generic v1 message instead. expected = f"{_field} to be an iterable of strings" if _field \ else "an iterable of strings" self._elements = _normalize_iterable_of_strings(elements, expected) self._on_change = _on_change def _changed(self) -> None: if self._on_change is not None: self._on_change()
[docs] def add(self, *strings: str) -> SetManager: """Add the normalized string arguments to the set. Returns ``self`` for chaining.""" # notify only on real change (v1 parity): a no-op add must not # bump the owner's generation changed = False try: for s in strings: normalized = _lc_validated(s) # TypeError on bytes (#245) if normalized not in self._elements: self._elements.add(normalized) changed = True finally: # a TypeError mid-list still leaves earlier additions # applied, so the owner must hear about them or its cache # goes stale (same rule as remove() below) if changed: self._changed() return self
[docs] def remove(self, *strings: str) -> SetManager: """Remove the normalized string arguments from the set. Raises ``KeyError`` if any argument is not a member. Returns ``self`` for chaining.""" changed = False try: for s in strings: # _lc_validated: bytes get the same decode-hint TypeError # as add() (#245) normalized = _lc_validated(s) self._elements.remove(normalized) # KeyError on missing (#243) changed = True finally: # a KeyError mid-list still leaves earlier removals applied, # so the owner must hear about them or its cache goes stale if changed: self._changed() return self
[docs] def discard(self, *strings: str) -> SetManager: """Remove the normalized string arguments from the set if present; missing members are ignored, like ``set.discard``. Returns ``self`` for chaining.""" changed = False for s in strings: normalized = _lc_validated(s) # bytes decode hint (#245) if normalized in self._elements: self._elements.discard(normalized) changed = True if changed: self._changed() return self
[docs] def clear(self) -> SetManager: """Remove all entries from the set. Returns ``self`` for chaining.""" if self._elements: self._elements.clear() self._changed() return self
def __contains__(self, item: object) -> bool: return isinstance(item, str) and lc(item) in self._elements def __iter__(self) -> Iterator[str]: return iter(self._elements) def __len__(self) -> int: return len(self._elements) def __eq__(self, other: object) -> bool: if isinstance(other, SetManager): return self._elements == other._elements if isinstance(other, (set, frozenset)): return self._elements == other return NotImplemented __hash__ = None # type: ignore[assignment] # mutable; v1 parity # -- set operators: accept ANY iterable (v1.3 normalize-everywhere) ----- # A bare str/bytes operand raises TypeError via _normalize_iterable_of_ # strings rather than iterating its characters (#238/#241); everything # else (list, generator, set, another SetManager, ...) is normalized # through lc() before the plain set op runs. def __or__(self, other: object) -> set[str]: return self._elements | _normalize_iterable_of_strings(other) __ror__ = __or__ def __and__(self, other: object) -> set[str]: return self._elements & _normalize_iterable_of_strings(other) __rand__ = __and__ def __sub__(self, other: object) -> set[str]: return self._elements - _normalize_iterable_of_strings(other) def __rsub__(self, other: object) -> set[str]: return _normalize_iterable_of_strings(other) - self._elements def __xor__(self, other: object) -> set[str]: return self._elements ^ _normalize_iterable_of_strings(other) __rxor__ = __xor__ # symmetric difference is commutative, like v1 # -- comparisons: v1 subclassed collections.abc.Set, whose __le__/__lt__/ # __ge__/__gt__ mixins only accept another Set-registered operand (set, # frozenset, or another Set subclass) -- NOT an arbitrary iterable like # list, unlike the operators above. Mirrored here since this SetManager # doesn't itself subclass the ABC. def __le__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented if len(self) > len(other): return False return all(elem in other for elem in self) def __lt__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented return len(self) < len(other) and self.__le__(other) def __ge__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented if len(self) < len(other): return False return all(elem in self for elem in other) def __gt__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented return len(self) > len(other) and self.__ge__(other) def __repr__(self) -> str: # Sorted so repr is stable across runs -- set() iteration order # depends on per-process string hash randomization. elements = ", ".join(repr(e) for e in sorted(self._elements)) return f"SetManager({{{elements}}})" if self._elements else "SetManager(set())" # -- pickle interop with v1 blobs --------------------------------------- def __getstate__(self) -> dict[str, object]: return {"_elements": set(self._elements)} def __setstate__(self, state: dict[str, object]) -> None: # v1 SetManager stored its set under `elements` (plain __dict__ # pickling); the shim stores `_elements`. Accept both, so a # v1.3/1.4 Constants blob's embedded managers unpickle straight # into shim instances. Re-normalize: nothing guarantees an # incoming blob's elements passed through lc(). elements: Iterable[str] = state.get( # type: ignore[assignment] "_elements", state.get("elements", ())) self._elements = {lc(e) for e in elements} self._on_change = None # rewired by the owning Constants
def _validated_mapping_args(args: tuple[object, ...]) -> tuple[object, ...]: """The #242 constructor guard, shared by TupleManager and _DelimiterManager: a bare str/bytes silently shreds into a garbage mapping (dict's own error), and an iterable of short strings silently splits each one into a (key, value) pair -- ported from v1's TupleManager.__init__ guard.""" if not args: return args arg = args[0] _reject_bare_str_or_bytes( arg, "a mapping or iterable of (key, value) pairs") if not isinstance(arg, Mapping): checked = [] for item in arg: # type: ignore[attr-defined] if isinstance(item, (str, bytes)): kind = "bytes" if isinstance(item, bytes) else "str" raise TypeError( f"expected (key, value) pairs, got a {kind} " f"element {item!r}; a 2-character string " "silently splits into a key and a value" ) checked.append(item) args = (checked, *args[1:]) return args
[docs] class TupleManager(dict[str, object]): """v1 ``TupleManager``: a dict with dot-notation access. Backs ``capitalization_exceptions``. Unknown-key attribute access raises ``AttributeError`` naming the key (#256, warned 1.4, enforced 2.0 -- the v1 ``DeprecationWarning`` is gone, this shim only speaks 2.0). Mutations call ``_on_change`` (the owning Constants' generation bump, wired by a later task). """ _on_change: Callable[[], None] | None def __init__(self, *args: object, _on_change: Callable[[], None] | None = None, **kwargs: object) -> None: args = _validated_mapping_args(args) super().__init__(*args, **kwargs) self._on_change = _on_change def _changed(self) -> None: if self._on_change is not None: self._on_change() def __getattr__(self, name: str) -> object: # Only reached for a missing attribute -- real instance attrs # (_on_change) and dict methods (keys, get, ...) resolve first # without ever hitting this. Dunder/underscore probes (pickling, # copy.deepcopy, IPython's _repr_html_) are never config keys. if name.startswith("_"): raise AttributeError(name) try: return self[name] except KeyError: # #256: name the known keys, like v1's 1.4 deprecation warning # did -- this shim only speaks 2.0, so what was a warning there # is a hard AttributeError here. raise AttributeError( f"{name!r} is not a known key " f"({', '.join(sorted(self))}); use .get() for intentional " "soft access." ) from None def __setattr__(self, name: str, value: object) -> None: # v1 parity: dunder probes (typing's __orig_class__, etc.) and this # shim's own _on_change hook get real object-attribute storage; # every other name -- including single-underscore ones, per v1 -- # routes to the dict so `t.mcdonald = 'x'` and `t['mcdonald'] = 'x'` # are the same operation. if name == "_on_change" or (name.startswith("__") and name.endswith("__")): object.__setattr__(self, name, value) else: self[name] = value def __delattr__(self, name: str) -> None: if name == "_on_change" or (name.startswith("__") and name.endswith("__")): object.__delattr__(self, name) else: del self[name] def __setitem__(self, key: str, value: object) -> None: super().__setitem__(key, value) self._changed() def __delitem__(self, key: str) -> None: super().__delitem__(key) self._changed()
[docs] def pop(self, *args: object) -> object: # bump only on a real removal -- pop(key, default) on a missing # key is a no-op read, not a mutation, same rule as SetManager's # no-op add() present = bool(args) and args[0] in self result = super().pop(*args) # type: ignore[call-overload] if present: self._changed() return result
[docs] def popitem(self) -> tuple[str, object]: result = super().popitem() # KeyError when empty: no bump self._changed() return result
[docs] def clear(self) -> None: had_items = bool(self) super().clear() if had_items: # clearing an empty dict is a no-op, not a change self._changed()
[docs] def update(self, *args: object, **kwargs: object) -> None: # dict.update's C path skips a subclass __setitem__; route every # item through it so subclass validation (_DelimiterManager's # sentinel rule) and the owner notification hold here too for key, value in dict(*args, **kwargs).items(): self[key] = value
[docs] def setdefault(self, key: str, default: object = None) -> object: if key in self: return self[key] # existing key: a read, not a mutation self[key] = default # validated + notifying path return default
# in-place |= must validate/notify like update; dict's C path would # skip both. mypy flags any non-overloaded __ior__ as inconsistent # with dict.__or__'s overloads -- the runtime behavior is the plain # dict |= contract, so the ignore is about typeshed shape only. def __ior__(self, other: object) -> Self: # type: ignore[override, misc] self.update(other) return self # -- pickle interop ------------------------------------------------- def __reduce__(self) -> tuple[type[TupleManager], tuple[()], dict[str, object]]: return (type(self), (), dict(self)) def __setstate__(self, state: dict[str, object]) -> None: # routes through __setitem__ (validated for _DelimiterManager); # _on_change is still None here, so no spurious bumps self.update(state) self._on_change = None # rewired by the owning Constants
#: The named delimiter buckets, translated to the ``Policy`` #: (open, close) pairs they stand for (spec §3). The first three are #: v1's; the rest are the #273 typographic conventions, named so the #: v1 keyed idioms (pop/move/del) work on them like the originals. #: Keep in sync with DEFAULT_NICKNAME_DELIMITERS in _policy.py (pinned #: by the default-Constants equality test). _SENTINEL_PAIRS = { "quoted_word": ("'", "'"), "double_quotes": ('"', '"'), "parenthesis": ("(", ")"), "smart_double_quotes": ("“", "”"), "low_high_quotes": ("„", "“"), "right_double_quotes": ("”", "”"), "guillemets": ("«", "»"), "reversed_guillemets": ("»", "«"), "corner_brackets": ("「", "」"), "white_corner_brackets": ("『", "』"), "fullwidth_parenthesis": ("(", ")"), } #: derived, so the manager's accepted keys and _snapshot()'s #: translation table can never drift apart _DELIMITER_SENTINELS = tuple(_SENTINEL_PAIRS)
[docs] class RegexTupleManager(TupleManager): # pickle-compat: do NOT delete """Pickle-compat alias only: v1.4's ``Constants.regexes`` field was a ``nameparser.config.RegexTupleManager`` instance (a ``TupleManager`` subclass whose ``__getattr__`` fell back to ``EMPTY_REGEX`` for an unknown key). Unpickling a v1.4 blob resolves and constructs this class -- via ``TupleManager.__reduce__``'s ``(type(self), (), state)`` -- before ``Constants.__setstate__`` runs, so the name must exist here even though the shim's ``Constants._snapshot()`` never reads it: ``regexes`` is a read-only ``_RegexesProxy`` in 2.0 (see above), and ``Constants.__setstate__`` below deliberately ignores an incoming ``regexes`` key rather than restoring it from this reconstructed (and otherwise unused) instance. """
class _DelimiterManager(TupleManager): """v1 ``nickname_delimiters``/``maiden_delimiters`` bucket. In 2.0 only the named sentinels in ``_DELIMITER_SENTINELS`` exist (spec §3; the v1 trio plus the #273 typographic pairs) -- assigning any other key raises so a caller reaches for a custom-delimiter Policy kwarg instead of a dict entry that silently does nothing. ``pop()``/ ``__setitem__``/``__delitem__`` stay open (inherited) for the documented bucket-move idiom, e.g. ``maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')``. """ def __init__(self, *args: object, _on_change: Callable[[], None] | None = None, **kwargs: object) -> None: # dict's C-level __init__ never calls a subclass __setitem__, so # collect and validate the initial items here -- BEFORE any item # lands -- or the sentinel rule silently misses the constructor. # The parent's #242 guard runs first: bare str/bytes gets the # friendly TypeError, not dict's cryptic one. args = _validated_mapping_args(args) items: dict[str, object] = dict(*args, **kwargs) for key in items: self._reject_non_sentinel(key) super().__init__(items, _on_change=_on_change) @staticmethod def _reject_non_sentinel(key: str) -> None: if key not in _DELIMITER_SENTINELS: raise TypeError( f"2.0 delimiter managers accept only the named sentinels " f"{_DELIMITER_SENTINELS}; for custom delimiter pairs use " f"Policy(nickname_delimiters=...) / maiden_delimiters" ) def __setitem__(self, key: str, value: object) -> None: self._reject_non_sentinel(key) super().__setitem__(key, value) # update/setdefault/|= inherit TupleManager's routing through # __setitem__, so they validate (and notify) for free class _RegexesProxy: """Read-only view over the v1 compiled patterns (``nameparser.config.regexes.REGEXES``). Reads keep working -- ``CONSTANTS.regexes.word`` stays informational -- but 2.0 configures parsing behavior through named ``Policy`` flags, not by mutating a regex, so any attribute *or* item assignment raises ``TypeError`` (spec §3's uniform read-only rule). """ @staticmethod def _regexes() -> Mapping[str, object]: # deferred import: see the module docstring's note on why every # nameparser.config.<data module> import in this file is lazy from nameparser.config.regexes import REGEXES return REGEXES #: dict methods this read-only proxy deliberately does not carry. #: Without this, __getattr__ reports them as missing regexes and #: sends the reader hunting for a vocabulary entry. _DICT_ONLY = frozenset({ "pop", "popitem", "setdefault", "update", "clear", "fromkeys", }) def __getattr__(self, name: str) -> object: if name.startswith("_"): raise AttributeError(name) if name in _RegexesProxy._DICT_ONLY: raise AttributeError( f"{name!r} is not supported on CONSTANTS.regexes: it is a " f"read-only view in 2.0, and parsing behavior is configured " f"through named Policy flags" ) try: return self._regexes()[name] except KeyError: raise AttributeError(f"no regex named {name!r}") from None def __getitem__(self, name: str) -> object: return self._regexes()[name] def __contains__(self, name: object) -> bool: return name in self._regexes() def __iter__(self) -> Iterator[str]: return iter(self._regexes()) def keys(self) -> KeysView[str]: return self._regexes().keys() # Defined explicitly because __getattr__ would otherwise claim each # of these names as a regex lookup and raise "no regex named # 'items'". The sibling managers inherit the whole surface from # dict; this proxy has to spell out the read half it supports. # #256's deprecation text promised .get() on both managers. def get(self, name: str, default: object = None) -> object: return self._regexes().get(name, default) def items(self) -> ItemsView[str, object]: return self._regexes().items() def values(self) -> ValuesView[object]: return self._regexes().values() def __len__(self) -> int: return len(self._regexes()) def copy(self) -> dict[str, object]: # v1's regexes was a dict subclass, so copy() returned a plain # mutable dict. Mutating the copy never affected parsing there # either, so this is a faithful read-only carry-over. return dict(self._regexes()) def __setattr__(self, name: str, value: object) -> None: self._raise_readonly(name) def __setitem__(self, name: str, value: object) -> None: self._raise_readonly(name) @staticmethod def _raise_readonly(name: str) -> None: # bidi/emoji are the two regexes v1 code toggled directly # (`CONSTANTS.regexes.bidi = False`) to opt out of stripping; # point those two at their named Policy replacement, everything # else gets the generic pointer. hints = { "bidi": "use Policy(strip_bidi=False) to keep bidi marks", "emoji": "use Policy(strip_emoji=False) to keep emoji", } hint = hints.get( name, "parsing behavior is configured through named Policy " "flags in 2.0; if none fits, open an issue") raise TypeError( f"assigning CONSTANTS.regexes.{name} is not supported in " f"2.0: {hint}" ) _SET_FIELDS = ( "prefixes", "suffix_acronyms", "suffix_not_acronyms", "suffix_acronyms_ambiguous", "titles", "first_name_titles", "conjunctions", "bound_first_names", "non_first_name_prefixes", ) _MANAGER_FIELDS = _SET_FIELDS + ( "capitalization_exceptions", "nickname_delimiters", "maiden_delimiters", ) #: v1's Constants.__repr__ field order (#221) -- kept as its own tuple #: rather than reusing _SET_FIELDS, whose order differs (v1 lists #: suffix_acronyms_ambiguous last, not fourth). _REPR_COLLECTION_ATTRS = ( "prefixes", "suffix_acronyms", "suffix_not_acronyms", "titles", "first_name_titles", "conjunctions", "bound_first_names", "non_first_name_prefixes", "suffix_acronyms_ambiguous", ) #: v1's repr scalar order, minus empty_attribute_default -- removed in 2.0 #: (#255), so there's no such attribute on this shim's Constants to show. _REPR_SCALAR_ATTRS = ( "string_format", "initials_format", "initials_delimiter", "initials_separator", "suffix_delimiter", "capitalize_name", "force_mixed_case_capitalization", "patronymic_name_order", "middle_name_as_last", ) _SCALAR_DEFAULTS: dict[str, object] = { "patronymic_name_order": False, "middle_name_as_last": False, "capitalize_name": False, "force_mixed_case_capitalization": False, "string_format": "{title} {first} {middle} {last} {suffix} ({nickname})", "initials_format": "{first} {middle} {last}", "initials_delimiter": ".", "initials_separator": " ", "suffix_delimiter": None, } # distinguishes "attribute not set yet" from any real scalar value # (None is a legitimate value for string_format/suffix_delimiter) _UNSET = object() # distinguishes "kwarg not passed to Constants()" (use library defaults) # from any real value a caller might pass, including a falsy one like "" _UNSET_KWARG = object() _SHARED_MUTATION_MESSAGE = ( "mutating the shared CONSTANTS singleton is deprecated and will be " "removed in 3.0; build a Lexicon/Policy (or a private Constants " "passed as HumanName(constants=...)) instead. See the migration " "guide." ) def _default_vocab() -> dict[str, set[str]]: # v1 data modules stay the single vocabulary source through 2.x # (same rule as Lexicon.default()). from nameparser.config.bound_first_names import BOUND_FIRST_NAMES from nameparser.config.conjunctions import CONJUNCTIONS from nameparser.config.prefixes import ( NON_FIRST_NAME_PREFIXES, PREFIXES, ) from nameparser.config.suffixes import ( SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_NOT_ACRONYMS, ) from nameparser.config.titles import FIRST_NAME_TITLES, TITLES return { "prefixes": PREFIXES, "suffix_acronyms": SUFFIX_ACRONYMS, "suffix_not_acronyms": SUFFIX_NOT_ACRONYMS, "suffix_acronyms_ambiguous": SUFFIX_ACRONYMS_AMBIGUOUS, "titles": TITLES, "first_name_titles": FIRST_NAME_TITLES, "conjunctions": CONJUNCTIONS, "bound_first_names": BOUND_FIRST_NAMES, "non_first_name_prefixes": NON_FIRST_NAME_PREFIXES, } class _RenderDefaults(NamedTuple): """v1 scalar rendering knobs that have no home on ``Policy`` (spec §3): ``__str__``/initials formatting and capitalization stay per-Constants defaults, layered onto a shared ``Parser`` by the facade (a later task) rather than folded into the cache key.""" string_format: str | None initials_format: str initials_delimiter: str initials_separator: str suffix_delimiter: str | None capitalize_name: bool force_mixed_case_capitalization: bool @functools.lru_cache(maxsize=64) def _cached_parser(lexicon: Lexicon, policy: Policy) -> Parser: # keyed on hashable value objects: shared across every facade whose # Constants resolve to the same snapshot (spec §3) return Parser(lexicon=lexicon, policy=policy)
[docs] class Constants: """v1 ``Constants`` shim: a mutable container whose state resolves to a frozen ``(Lexicon, Policy, _RenderDefaults)`` snapshot via ``_snapshot()``. ``_generation`` increments on every mutation; facades compare it against a cached value to decide whether their snapshot is stale (dirty-tracking, spec §3 -- the facade itself is a later task). The module-level ``CONSTANTS`` singleton (below) has ``_shared`` flipped to ``True``: any mutation reached through it emits ``DeprecationWarning`` pointing at ``Lexicon``/``Policy`` and ``HumanName(constants=...)``. A private ``Constants()`` never warns -- only the shared instance is on the 3.0 removal path. """ _shared = False # the CONSTANTS singleton flips this to True _generation: int prefixes: SetManager suffix_acronyms: SetManager suffix_not_acronyms: SetManager suffix_acronyms_ambiguous: SetManager titles: SetManager first_name_titles: SetManager conjunctions: SetManager bound_first_names: SetManager non_first_name_prefixes: SetManager capitalization_exceptions: TupleManager nickname_delimiters: _DelimiterManager maiden_delimiters: _DelimiterManager regexes: _RegexesProxy patronymic_name_order: bool middle_name_as_last: bool capitalize_name: bool force_mixed_case_capitalization: bool string_format: str | None initials_format: str initials_delimiter: str initials_separator: str suffix_delimiter: str | None def __init__( self, *, prefixes: Iterable[str] | object = _UNSET_KWARG, suffix_acronyms: Iterable[str] | object = _UNSET_KWARG, suffix_not_acronyms: Iterable[str] | object = _UNSET_KWARG, suffix_acronyms_ambiguous: Iterable[str] | object = _UNSET_KWARG, titles: Iterable[str] | object = _UNSET_KWARG, first_name_titles: Iterable[str] | object = _UNSET_KWARG, conjunctions: Iterable[str] | object = _UNSET_KWARG, bound_first_names: Iterable[str] | object = _UNSET_KWARG, non_first_name_prefixes: Iterable[str] | object = _UNSET_KWARG, capitalization_exceptions: Mapping[str, str] | Iterable[tuple[str, str]] | object = _UNSET_KWARG, regexes: object = _UNSET_KWARG, patronymic_name_order: bool = False, middle_name_as_last: bool = False, ) -> None: # v1.4 parity constructor kwargs (#238/#242/#244 hardening); the # signature is spelled out rather than **kwargs so an unknown # keyword raises Python's own TypeError with no help from here. # `regexes` is the one deliberate 2.0 divergence: v1.4 accepted it # (RegexTupleManager(regexes)), but constructor injection is # assignment by another door, and __setattr__ above already # forbids `c.regexes = ...` post-construction -- the uniform 2.0 # rule is that parsing behavior is configured through Policy, not # by handing Constants a compiled-pattern mapping either way. if regexes is not _UNSET_KWARG: raise TypeError( "Constants(regexes=...) is not supported in 2.0; parsing " "behavior is configured through named Policy flags, not " "by constructing Constants with a regex mapping. See the " "migration guide." ) overrides = { "prefixes": prefixes, "suffix_acronyms": suffix_acronyms, "suffix_not_acronyms": suffix_not_acronyms, "suffix_acronyms_ambiguous": suffix_acronyms_ambiguous, "titles": titles, "first_name_titles": first_name_titles, "conjunctions": conjunctions, "bound_first_names": bound_first_names, "non_first_name_prefixes": non_first_name_prefixes, } vocab = _default_vocab() object.__setattr__(self, "_generation", 0) for name in _SET_FIELDS: value = overrides[name] if value is _UNSET_KWARG: value = vocab[name] # a caller-supplied value REPLACES that field's default # vocabulary wholesale (v1 parity); SetManager itself validates/ # normalizes and rejects a bare str/bytes (#238/#241), naming # this field in the message via _field= object.__setattr__( self, name, SetManager(value, _on_change=self._bump, _field=name)) # type: ignore[arg-type] if capitalization_exceptions is _UNSET_KWARG: from nameparser.config.capitalization import ( CAPITALIZATION_EXCEPTIONS, ) capitalization_exceptions = CAPITALIZATION_EXCEPTIONS object.__setattr__(self, "capitalization_exceptions", TupleManager( capitalization_exceptions, # type: ignore[arg-type] _on_change=self._bump)) object.__setattr__(self, "nickname_delimiters", _DelimiterManager( {name: name for name in _DELIMITER_SENTINELS}, _on_change=self._bump)) object.__setattr__(self, "maiden_delimiters", _DelimiterManager( _on_change=self._bump)) object.__setattr__(self, "regexes", _RegexesProxy()) for name, value in _SCALAR_DEFAULTS.items(): object.__setattr__(self, name, value) # the two behavior bools were v1.4 constructor kwargs too # (docs/customize.rst doctests use them); truthiness matches v1, # storage goes through the plain scalar slot if patronymic_name_order: object.__setattr__(self, "patronymic_name_order", True) if middle_name_as_last: object.__setattr__(self, "middle_name_as_last", True) def _invalidate_pst(self) -> None: """Pickle-compat alias only, never called at runtime: v1's four cached-union ``SetManager`` fields (``prefixes``, ``suffix_acronyms``, ``suffix_not_acronyms``, ``titles``) stored their ``_on_change`` as the bound method ``Constants._invalidate_pst``. A pickled bound method serializes as a back-reference to its ``__self__`` (this same ``Constants`` instance, mid-unpickle) plus the method NAME -- reconstructed via ``getattr(constants_obj, '_invalidate_pst')`` before ``Constants.__setstate__`` ever runs (same two-phase-unpickling story as ``RegexTupleManager`` above). Without this name, loading a v1.4 blob raises ``AttributeError`` looking up the method. ``SetManager.__setstate__`` (shim) never restores ``_on_change`` from pickled state regardless -- it always resets to ``None`` and is rewired by ``Constants.__setstate__`` below -- so this bound method value is reconstructed only to satisfy the pickle format; it is discarded immediately and never invoked. """ def _bump(self) -> None: # stacklevel=3 is exact for the direct scalar-assignment path # (user code -> Constants.__setattr__ -> here) and lands one # frame short -- inside the manager's own add()/remove()/ # __setitem__ -- for the indirect manager-mutation path (user # code -> manager method -> _changed() -> here), since a # single warn() call can't be exact for both call depths at # once. Either way the warning still fires from this module, # not the manager's true caller, which is enough: only the # DeprecationWarning's presence/category/message are load- # bearing (see the specified test), not the reported line. if self._shared: warnings.warn(_SHARED_MUTATION_MESSAGE, DeprecationWarning, stacklevel=3) object.__setattr__(self, "_generation", self._generation + 1) def __setattr__(self, name: str, value: object) -> None: if name == "_shared": # the flag the whole shared-mutation DeprecationWarning # mechanism hinges on: only the module-level singleton flip # (object.__setattr__ below) may set it raise AttributeError( "Constants._shared is read-only; it marks the module " "singleton and is set once at import" ) if name == "empty_attribute_default": raise AttributeError( "empty_attribute_default was removed in 2.0 (#255): " "empty attributes are always ''" ) if name == "regexes": raise TypeError( "replacing CONSTANTS.regexes is not supported in 2.0; " "parsing behavior is configured through named Policy " "flags" ) if name in _SET_FIELDS: # v1 allowed wholesale reassignment (c.titles = {...}); same # bare-str/bytes guard as the constructor kwarg path (#238/#241) value = SetManager( value, _on_change=self._bump, _field=name) # type: ignore[arg-type] elif name == "capitalization_exceptions": value = TupleManager(value, _on_change=self._bump) # type: ignore[arg-type] elif name in ("nickname_delimiters", "maiden_delimiters"): value = _DelimiterManager(value, _on_change=self._bump) # type: ignore[arg-type] elif name in _SCALAR_DEFAULTS and \ getattr(self, name, _UNSET) == value: # no-op scalar assignment: managers already suppress no-op # mutations, so re-assigning the current scalar value must # not bump the generation (or warn on the shared singleton) # either. __init__ writes via object.__setattr__, so this # only runs on real user assignments -- _UNSET never # actually matches, it just keeps a not-yet-set attribute # from raising here. Manager-field reassignment above stays # an unconditional bump: comparing manager contents isn't # worth it. object.__setattr__(self, name, value) return object.__setattr__(self, name, value) if name in _MANAGER_FIELDS or name in _SCALAR_DEFAULTS: self._bump()
[docs] def copy(self) -> Constants: """Independent copy (#260), subclass-preserving. Divergence from v1 (which deepcopied __dict__): attributes OUTSIDE the known field surface -- e.g. ad-hoc names stashed on the instance -- are not carried by copy() or pickling; only the enumerated fields survive.""" # #260 # An independent instance with its own generation counter and # its own manager callbacks -- not a shared-state alias like a # naive attribute-for-attribute copy would produce. v1's copy() # was `copy.deepcopy(self)`, which builds the new object via # `type(self).__new__(type(self))` -- NOT by calling `type(self)()` # -- so a Constants subclass copies as itself without its __init__ # running (and without needing to satisfy whatever signature that # __init__ might require). Mirrored here with an explicit __new__ # bypass rather than type(self)(). new = object.__new__(type(self)) object.__setattr__(new, "_generation", 0) for name in _SET_FIELDS: object.__setattr__( new, name, SetManager(getattr(self, name), _on_change=new._bump)) object.__setattr__(new, "capitalization_exceptions", TupleManager( dict(self.capitalization_exceptions), _on_change=new._bump)) for bucket in ("nickname_delimiters", "maiden_delimiters"): object.__setattr__(new, bucket, _DelimiterManager( dict(getattr(self, bucket)), _on_change=new._bump)) object.__setattr__(new, "regexes", _RegexesProxy()) for name in _SCALAR_DEFAULTS: object.__setattr__(new, name, getattr(self, name)) return new
def __repr__(self) -> str: # #221 # Collections (some with hundreds of entries, e.g. titles/prefixes) # are summarized as counts rather than dumped in full, like v1. # Scalars are only shown when they differ from the library default # -- _SCALAR_DEFAULTS stands in for v1's `getattr(type(self), name)` # class-level default, since this shim's scalar defaults are # instance attributes set in __init__, not class attributes. lines = [f" {name}: {len(getattr(self, name))}" for name in _REPR_COLLECTION_ATTRS] lines += [ f" {name}: {value!r}" for name in _REPR_SCALAR_ATTRS if (value := getattr(self, name)) != _SCALAR_DEFAULTS[name] ] return "<Constants : [\n" + "\n".join(lines) + "\n]>" # -- snapshot ----------------------------------------------------------- def _snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: # generation-keyed cache: rebuilding the Lexicon re-normalizes # ~1400 vocabulary entries (~185us) -- the same dirty-tracking # the facade uses, applied one level up. A pure read either way # (no bump, no warning). cached = getattr(self, "_snapshot_cache", None) if cached is not None and cached[0] == self._generation: return cached[1] # type: ignore[no-any-return] snapshot = self._build_snapshot() object.__setattr__( self, "_snapshot_cache", (self._generation, snapshot)) return snapshot def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: """Resolve this v1-shaped, mutable Constants into the frozen 2.0 value objects it corresponds to (spec §3). A pure read: no generation bump, no deprecation warning even on the shared singleton -- only direct attribute mutation is on the 3.0 removal path. """ from nameparser.config.maiden_markers import MAIDEN_MARKERS acronyms = frozenset(self.suffix_acronyms) particles = frozenset(self.prefixes) bound = frozenset(self.bound_first_names) ambiguous_acronyms = frozenset(self.suffix_acronyms_ambiguous) & acronyms # keep in sync with _lexicon._default_lexicon() (pinned by # tests/v2/test_config_shim.py::test_snapshot_field_translation) lexicon = Lexicon( titles=frozenset(self.titles), # TRANSLATE, do not filter. The two versions build the same # lookup key differently: v1 joins the raw title run and # then applies lc(), which strips only the whole string's # edge periods, so an interior word keeps its own ("lt. # col"). v2 normalizes each token and then joins ("lt col"). # Re-folding per word converts a v1 entry into the v2 # spelling; filtering instead dropped every multi-word # honorific containing an abbreviation or a conjunction and # silently swapped given and family. # Only entries v1 could actually match: its key is the # joined title run, so always single-spaced and never # empty. An entry holding a whitespace run was inert there # (translating it would start matching), and one that folds # away entirely would trip _normset's empty-entry check on # a config v1 simply ignored. given_name_titles=frozenset( t for t in ( _title_key(e.split()) for e in self.first_name_titles if e == " ".join(e.split()) ) if t), suffix_acronyms=acronyms, # Drop any ambiguous acronym from the word set rather than # the other way round. Lexicon forbids the overlap because # the word branch bypasses the period gate, and adding an # ambiguous acronym to suffix_not_acronyms is INERT in v1 # anyway: is_suffix already accepts it via the acronym # branch, and reserve_last keeps it as the surname. So # ignoring the addition reproduces v1 ("Jack Ma" keeps # last='Ma'), where dropping it from the AMBIGUOUS set # instead ungated the word and lost the family name -- # a silent misparse worse than the raise it avoided. suffix_words=frozenset( self.suffix_not_acronyms) - ambiguous_acronyms, # Intersect with acronyms: Lexicon enforces ambiguous <= # acronyms; v1 behaves the same when an acronym is deleted # but its ambiguous entry lingers (the entry stops # mattering). suffix_acronyms_ambiguous=ambiguous_acronyms, particles=particles, # complement translation: v1 marks the never-given subset; # v2 marks the may-be-given subset. The trailing union keeps # a config v1 accepted: prefixes.py asserts its own data has # no word in both non_first_name_prefixes and # bound_first_names, but nothing stops a caller adding one at # runtime, and v1 then lets the bound rule win (leading "dos # Santos Silva" parses first="dos Santos"). Treating such a # word as may-be-given reproduces that rather than raising. # # KNOWN DEVIATION, pinned by # test_bound_never_given_prefix_deviates_on_two_pieces: v1's # join has a reserve_last guard, so with only two pieces it # does NOT fire and the word stays never-given ("dos Santos" # -> last="dos Santos"). Promotion here is unconditional, so # that case reads given="dos", family="Santos". v1's rule is # piece-count dependent and a static vocabulary set cannot # express it; the alternative is raising on a config v1 # accepted, which is worse. Only reachable via a runtime # config the shipped data forbids. particles_ambiguous=( particles - frozenset(self.non_first_name_prefixes)) | (bound & particles), conjunctions=frozenset(self.conjunctions), bound_given_names=bound, # v1 Constants has no manager for these (#274 is 2.0 # behavior); the data module is the only source maiden_markers=frozenset(MAIDEN_MARKERS), # TupleManager is dict[str, object] (v1 parity: values were # never statically str-typed); every real entry is a str, # same assumption _DelimiterManager's sentinel lookup makes capitalization_exceptions=tuple( sorted(self.capitalization_exceptions.items())), # type: ignore[arg-type] ) rules = frozenset({PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) \ if self.patronymic_name_order else frozenset() policy = Policy( patronymic_rules=rules, middle_as_family=self.middle_name_as_last, nickname_delimiters=frozenset( _SENTINEL_PAIRS[k] for k in self.nickname_delimiters), # v1 precedence: a pair in BOTH v1 buckets parses as nickname. # Policy itself resolves overlap the other way (maiden wins), # so pre-subtract here to keep the facade at v1 behavior. maiden_delimiters=frozenset( _SENTINEL_PAIRS[k] for k in self.maiden_delimiters if k not in self.nickname_delimiters), # suffix_delimiter is a _RenderDefaults-only field here; the # facade layers it onto extra_suffix_delimiters per instance # (a later task) -- _snapshot() itself stays pure translation ) defaults = _RenderDefaults( self.string_format, self.initials_format, self.initials_delimiter, self.initials_separator, self.suffix_delimiter, self.capitalize_name, self.force_mixed_case_capitalization) return lexicon, policy, defaults # -- pickle ----------------------------------------------------------- def __getstate__(self) -> dict[str, object]: state: dict[str, object] = {} for name in _SET_FIELDS: state[name] = set(getattr(self, name)) state["capitalization_exceptions"] = dict( self.capitalization_exceptions) state["nickname_delimiters"] = dict(self.nickname_delimiters) state["maiden_delimiters"] = dict(self.maiden_delimiters) for name in _SCALAR_DEFAULTS: state[name] = getattr(self, name) return state def __setstate__(self, state: dict[str, object]) -> None: if "suffixes_prefixes_titles" in state: # pre-1.3.0 blob: its dir()-sweep __getstate__ captured this # computed property. The 1.4 DeprecationWarning promised # ValueError in 2.0 (#279). raise ValueError( "this pickle was written by nameparser <= 1.2.x (#279); " "re-pickle under 1.3/1.4 to migrate, or re-create the " "configuration. See " "https://github.com/derek73/python-nameparser/issues/279" ) # Accepts BOTH shapes with a single overlay: the shim's own # state and v1.3/1.4 state (public field names -> manager/ # scalar values) share every key that matters, so no shape # marker is needed. empty_attribute_default is accepted and # DROPPED (#255: empty is always '' in 2.0). state = {k: v for k, v in state.items() if k != "empty_attribute_default"} self.__init__() # type: ignore[misc] # defaults, then overlay # (managers re-wrapped below so _on_change points at THIS # instance, not whatever produced the incoming state) for name in _SET_FIELDS: if name in state: object.__setattr__(self, name, SetManager( state[name], _on_change=self._bump)) # type: ignore[arg-type] if "capitalization_exceptions" in state: object.__setattr__( self, "capitalization_exceptions", TupleManager( state["capitalization_exceptions"], # type: ignore[arg-type] _on_change=self._bump)) for bucket in ("nickname_delimiters", "maiden_delimiters"): if bucket in state: object.__setattr__(self, bucket, _DelimiterManager( state[bucket], _on_change=self._bump)) # type: ignore[arg-type] for name in _SCALAR_DEFAULTS: if name in state: object.__setattr__(self, name, state[name])
CONSTANTS = Constants() object.__setattr__(CONSTANTS, "_shared", True)