"""The 2.0 ``HumanName`` facade (migration spec §2): a mutable wrapper
over a frozen ParsedName, delegating parsing to the core Parser resolved
from the bound Constants shim. Keeps every v1 spelling. Deleted in 3.0.
Layering: facade layer -- may import anything public plus _render.
"""
from __future__ import annotations
import dataclasses
import warnings
from collections.abc import Iterator, Mapping
from typing import Any
# Import order matters here -- breaks a real import cycle. nameparser.
# config's package __init__ re-exports CONSTANTS/Constants/etc. from
# _config_shim (the v1 nameparser.config.Constants compat path), while
# _config_shim's own default CONSTANTS singleton needs nameparser.config's
# DATA submodules (titles, prefixes, ...), imported lazily -- see
# _config_shim.py's module docstring. If _config_shim were the first of
# the two ever touched, building its CONSTANTS would need to import
# nameparser.config, whose __init__ would in turn need _config_shim's
# (not-yet-built) CONSTANTS: ImportError. Importing the config package
# here first lets its __init__ run to completion; when IT then imports
# _config_shim to build the default CONSTANTS, nameparser.config is
# already registered in sys.modules, so its data-submodule imports
# resolve directly instead of re-entering (and failing on) its own
# still-executing __init__.
import nameparser.config # noqa: F401
import nameparser._render as _render
from nameparser._config_shim import CONSTANTS, Constants, _cached_parser
from nameparser._lexicon import _normalize
from nameparser._parser import Parser
from nameparser._types import FOLDED_TAG, ParsedName, Role, Token
_V2_FIELD = {"first": "given", "last": "family"} # v1 name -> v2 name
_V1_SPELLING = {v2: v1 for v1, v2 in _V2_FIELD.items()}
# derived from Role: declaration order IS the canonical field order
# (never restated), rendered in the v1 spellings
_MEMBERS = tuple(_V1_SPELLING.get(r.value, r.value) for r in Role)
#: v1 parsing hooks the facade never calls (spec §2 exception 2 / #280).
_V1_HOOKS = (
"pre_process", "post_process", "parse_full_name", "parse_pieces",
"parse_nicknames", "join_on_conjunctions", "squash_emoji",
"handle_firstnames", "handle_middle_name_as_last",
"is_title", "is_conjunction", "is_prefix", "is_roman_numeral",
"is_suffix", "is_suffix_lenient", "is_an_initial",
)
# Module-level mutable state (sanctioned exception, AGENTS.md "One
# sanctioned global"): strong-references every distinct HumanName
# subclass for process lifetime so the hook warning fires once per
# class. Fine in practice -- subclasses are statically defined, and the
# whole module is deleted with the facade layer in 3.0.
_WARNED_SUBCLASSES: set[type] = set()
def _empty_parsed() -> ParsedName:
return ParsedName(original="", tokens=(), ambiguities=())
[docs]
class HumanName:
"""v1 ``HumanName`` facade: a mutable wrapper over a frozen
``ParsedName``, delegating all parsing to the core ``Parser``
resolved from the bound ``Constants`` shim (dirty-tracked via its
``_generation``). Keeps every v1 spelling (``first``/``last`` over
the core ``given``/``family``); the v1 parsing hooks are never
called (#280). Deleted with the facade layer in 3.0.
"""
[docs]
def __init__(
self,
full_name: str = "",
constants: Constants | None = CONSTANTS,
string_format: str | None = None,
initials_format: str | None = None,
initials_delimiter: str | None = None,
initials_separator: str | None = None,
suffix_delimiter: str | None = None,
first: str | list[str] | None = None,
middle: str | list[str] | None = None,
last: str | list[str] | None = None,
title: str | list[str] | None = None,
suffix: str | list[str] | None = None,
nickname: str | list[str] | None = None,
maiden: str | list[str] | None = None,
) -> None:
if constants is None:
raise TypeError(
"constants=None was removed in 2.0 (#261): pass a "
"Constants instance, or use the new Parser/Lexicon/"
"Policy API for per-call configuration"
)
if not isinstance(constants, Constants):
raise TypeError(
f"constants must be a Constants instance, got {constants!r}"
)
self._warn_overridden_hooks()
self._C = constants
self._snapshot_gen = -1 # forces first resolve
_, _, defaults = constants._snapshot()
self.string_format = (string_format if string_format is not None
else defaults.string_format)
self.initials_format = (initials_format if initials_format is not None
else defaults.initials_format)
self.initials_delimiter = (
initials_delimiter if initials_delimiter is not None
else defaults.initials_delimiter)
self.initials_separator = (
initials_separator if initials_separator is not None
else defaults.initials_separator)
# These five assignments route through the validating properties
# below. The suffix_delimiter setter resets _snapshot_gen to -1
# on every assignment (including this one, harmlessly -- it's
# already -1 above), so reassigning it post-construction (e.g.
# n.suffix_delimiter = " - ") correctly forces the next
# _resolve() to rebuild the Policy with the new delimiter.
self.suffix_delimiter = (suffix_delimiter if suffix_delimiter is not None
else defaults.suffix_delimiter)
self._full_name = ""
self._parsed = _empty_parsed()
if first or middle or last or title or suffix or nickname or maiden:
# These route through the field-setter properties (None
# clears the field); no full-string parse, full_name stays "".
self.first = first
self.middle = middle
self.last = last
self.title = title
self.suffix = suffix
self.nickname = nickname
self.maiden = maiden
else:
self._apply_full_name(full_name)
@classmethod
def _warn_overridden_hooks(cls) -> None:
if cls is HumanName or cls in _WARNED_SUBCLASSES:
return
overridden = [h for h in _V1_HOOKS
if getattr(cls, h, None) is not getattr(
HumanName, h, None)]
_WARNED_SUBCLASSES.add(cls)
if overridden:
warnings.warn(
f"{cls.__name__} overrides v1 parsing hooks "
f"({', '.join(overridden)}) that the 2.0 facade never "
f"calls; parsing is delegated to the core Parser. "
f"Migrate to the Lexicon/Policy API. See "
f"https://github.com/derek73/python-nameparser/issues/280",
DeprecationWarning, stacklevel=3)
# -- render defaults -----------------------------------------------------
# One-line validating setters (spec §2): assigning a non-str (or, for
# the two fields that allow it, non-str-non-None) raises TypeError at
# assignment time instead of failing later inside .format().
@property
def string_format(self) -> str | None:
return self._string_format
@string_format.setter
def string_format(self, value: str | None) -> None:
if value is not None and not isinstance(value, str):
raise TypeError(
f"string_format must be a str or None, got {value!r}")
self._string_format = value
@property
def initials_format(self) -> str:
return self._initials_format
@initials_format.setter
def initials_format(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError(
f"initials_format must be a str, got {value!r}")
self._initials_format = value
@property
def initials_delimiter(self) -> str:
return self._initials_delimiter
@initials_delimiter.setter
def initials_delimiter(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError(
f"initials_delimiter must be a str, got {value!r}")
self._initials_delimiter = value
@property
def initials_separator(self) -> str:
return self._initials_separator
@initials_separator.setter
def initials_separator(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError(
f"initials_separator must be a str, got {value!r}")
self._initials_separator = value
@property
def suffix_delimiter(self) -> str | None:
return self._suffix_delimiter
@suffix_delimiter.setter
def suffix_delimiter(self, value: str | None) -> None:
if value is not None and not isinstance(value, str):
raise TypeError(
f"suffix_delimiter must be a str or None, got {value!r}")
self._suffix_delimiter = value
# Invalidate the cached Policy: _resolve() layers suffix_delimiter
# onto extra_suffix_delimiters, so a stale snapshot would keep
# parsing against the old delimiter.
self._snapshot_gen = -1
# -- config / parsing ---------------------------------------------------
def _resolve(self) -> Parser:
"""Dirty-tracked parser resolution (spec §3): rebuild the
snapshot only when the bound Constants' generation moved."""
gen = self._C._generation
if self._snapshot_gen != gen:
lexicon, policy, _ = self._C._snapshot()
if self.suffix_delimiter:
policy = dataclasses.replace(
policy,
extra_suffix_delimiters=frozenset(
{self.suffix_delimiter}))
self._lexicon, self._policy = lexicon, policy
self._parser = _cached_parser(lexicon, policy)
self._snapshot_gen = gen
# the fast path is a plain attribute return: hashing the two
# value objects for the lru lookup is the whole fast-path cost
return self._parser
[docs]
def parse_full_name(self) -> None:
"""Re-parse the stored ``full_name`` (v1's documented re-parse
trigger, docs/customize.rst): mutate ``name.C`` then call this to
force a re-parse without reassigning ``full_name``. The v1
parsing INTERNALS this name evokes live in the core ``Parser``,
not here; a subclass overriding this method still triggers the
#280 hook-override warning, and full_name assignment never
consults it."""
self._apply_full_name(self._full_name)
def _apply_full_name(self, value: str) -> None:
if isinstance(value, bytes):
raise TypeError(
"bytes input was removed in 2.0 (#245): decode first, "
"e.g. HumanName(raw.decode('utf-8'))"
)
if not isinstance(value, str):
raise TypeError(f"full_name must be a str, got {value!r}")
# parse FIRST: if snapshot resolution raises, the instance must
# not be left with a new full_name over the old parsed fields
parsed = self._resolve().parse(value)
self._full_name = value
self._parsed = parsed
if self._C.capitalize_name:
self.capitalize() # v1 parser.py:1653 parity
[docs]
def capitalize(self, force: bool | None = None) -> None:
"""Re-capitalize the current parse against the bound lexicon.
force=None reads the bound Constants' render default
(force_mixed_case_capitalization); the core's capitalized()
implements the single-case gate (v1 parity) -- not
re-implemented here."""
self._resolve()
if force is None:
force = self._C.force_mixed_case_capitalization
self._parsed = self._parsed.capitalized(self._lexicon, force=force)
@property
def full_name(self) -> str:
return self._full_name
@full_name.setter
def full_name(self, value: str) -> None:
self._apply_full_name(value)
@property
def original(self) -> str:
return self._parsed.original or self._full_name
@property
def C(self) -> Constants:
return self._C
@C.setter
def C(self, constants: Constants | None) -> None:
# v1.4 closed #239 by making C a validating setter that ONLY
# stores the new value -- no re-parse (checked against v1.4:
# `git show 2d5d8c2:nameparser/parser.py` lines ~204-206, the C
# setter body is exactly `self._C = self._validate_constants(...)`).
# A caller who wants the new config reflected must still trigger
# a re-parse, e.g. via parse_full_name() or a full_name
# reassignment -- matched here rather than re-parsing eagerly.
if constants is None:
raise TypeError(
"assigning constants=None to C was removed in 2.0 (#261): "
"pass a Constants instance, or use the new Parser/Lexicon/"
"Policy API for per-call configuration"
)
if not isinstance(constants, Constants):
raise TypeError(
f"constants must be a Constants instance, got {constants!r}"
)
self._C = constants
self._snapshot_gen = -1 # invalidate: next _resolve() rebuilds
@property
def has_own_config(self) -> bool:
"""True when this instance is not using the shared module-level
CONSTANTS."""
return self._C is not CONSTANTS
# -- fields ---------------------------------------------------------
def _get_field(self, member: str) -> str:
return getattr(self._parsed, _V2_FIELD.get(member, member))
def _set_field(self, member: str, value: str | list[str] | None) -> None:
if value is None:
joined = ""
elif isinstance(value, list):
for element in value:
if not isinstance(element, str):
raise TypeError(
f"name parts must be strings, got {element!r}")
joined = " ".join(value)
elif isinstance(value, str):
joined = value
else:
raise TypeError(
f"{member} must be a str, list, or None, got {value!r}")
self._parsed = self._parsed.replace(
**{_V2_FIELD.get(member, member): joined})
def _list_for(self, member: str) -> list[str]:
# A "joined" continuation token ("Ph." + "D.") belongs to its
# predecessor's part, matching v1's fix_phd (suffix_list had ONE
# "Ph. D." element). ParsedName._text_for heals only the suffix
# string view (the ", " join); the facade list view heals for
# every role -- a continuation is never its own list element.
role = Role(_V2_FIELD.get(member, member))
parts: list[str] = []
folded: list[str] = []
for tok in self._parsed.tokens_for(role):
if "joined" in tok.tags and parts:
parts[-1] += " " + tok.text
elif FOLDED_TAG in tok.tags:
# middle_as_family fold: v1 PREPENDED middle_list to
# last_list -- keep the list view consistent with the
# string view (_text_for orders folded-first too)
folded.append(tok.text)
else:
parts.append(tok.text)
return folded + parts
@property
def title(self) -> str:
return self._get_field("title")
@title.setter
def title(self, value: str | list[str] | None) -> None:
self._set_field("title", value)
@property
def title_list(self) -> list[str]:
return self._list_for("title")
@property
def first(self) -> str:
return self._get_field("first")
@first.setter
def first(self, value: str | list[str] | None) -> None:
self._set_field("first", value)
@property
def first_list(self) -> list[str]:
return self._list_for("first")
@property
def middle(self) -> str:
return self._get_field("middle")
@middle.setter
def middle(self, value: str | list[str] | None) -> None:
self._set_field("middle", value)
@property
def middle_list(self) -> list[str]:
return self._list_for("middle")
@property
def last(self) -> str:
return self._get_field("last")
@last.setter
def last(self, value: str | list[str] | None) -> None:
self._set_field("last", value)
@property
def last_list(self) -> list[str]:
return self._list_for("last")
@property
def suffix(self) -> str:
return self._get_field("suffix")
@suffix.setter
def suffix(self, value: str | list[str] | None) -> None:
self._set_field("suffix", value)
@property
def suffix_list(self) -> list[str]:
return self._list_for("suffix")
@property
def nickname(self) -> str:
return self._get_field("nickname")
@nickname.setter
def nickname(self, value: str | list[str] | None) -> None:
self._set_field("nickname", value)
@property
def nickname_list(self) -> list[str]:
return self._list_for("nickname")
@property
def maiden(self) -> str:
return self._get_field("maiden")
@maiden.setter
def maiden(self, value: str | list[str] | None) -> None:
self._set_field("maiden", value)
@property
def maiden_list(self) -> list[str]:
return self._list_for("maiden")
# -- derived views ----------------------------------------------------
@property
def surnames_list(self) -> list[str]:
return self.middle_list + self.last_list
@property
def surnames(self) -> str:
return " ".join(self.surnames_list)
@property
def given_names_list(self) -> list[str]:
return self.first_list + self.middle_list
@property
def given_names(self) -> str:
return " ".join(self.given_names_list)
def _is_particle(self, text: str) -> bool:
self._resolve()
return _normalize(text) in self._lexicon.particles
def _is_conjunction(self, text: str) -> bool:
self._resolve()
return _normalize(text) in self._lexicon.conjunctions
def _split_last(self) -> tuple[list[str], list[str]]:
# v1 parser.py _split_last, verbatim: vocabulary lookup at ACCESS
# time (so assigned last names split too), with the all-particle
# guard (a family name is assumed not to consist entirely of
# particles, e.g. surname "Do" which also appears in PREFIXES)
words = " ".join(self.last_list).split()
i = 0
while i < len(words) and self._is_particle(words[i]):
i += 1
if i == len(words):
return [], words
return words[:i], words[i:]
@property
def last_prefixes_list(self) -> list[str]:
return self._split_last()[0]
@property
def last_prefixes(self) -> str:
return " ".join(self._split_last()[0])
@property
def last_base_list(self) -> list[str]:
return self._split_last()[1]
@property
def last_base(self) -> str:
return " ".join(self._split_last()[1])
# -- initials -------------------------------------------------------------
def _process_initial(self, name_part: str, firstname: bool = False) -> str:
# v1 parser.py:427 verbatim: particles/conjunctions are filtered
# from initials unless the part is a first name. split() rather
# than split(" "): *_list attributes assigned directly bypass
# whitespace normalization, and split(" ") yields empty strings
# for repeated spaces (#232).
parts = name_part.split()
initials = []
for part in parts:
if not (self._is_particle(part)
or self._is_conjunction(part)) or firstname:
initials.append(part[0])
if len(initials) > 0:
return self.initials_separator.join(initials)
# Return '' (never empty_attribute_default, which may be None)
# when a part has no initialable words, e.g. a middle name
# consisting only of prefixes ("de la"). Callers drop these
# parts entirely.
return ""
def _initials_lists(self) -> tuple[list[str], list[str], list[str]]:
"""Initials for the first, middle and last name groups. Parts
that yield no initials (e.g. a prefix-only middle name like
"de la") are dropped rather than kept as empty strings.
"""
def group_initials(names: list[str],
firstname: bool = False) -> list[str]:
return [i for i in (self._process_initial(n, firstname)
for n in names if n) if i]
return (group_initials(self.first_list, True),
group_initials(self.middle_list),
group_initials(self.last_list))
def initials_list(self) -> list[str]:
first, middle, last = self._initials_lists()
return first + middle + last
def initials(self) -> str:
first, middle, last = self._initials_lists()
joiner = self.initials_delimiter + self.initials_separator
def group(items: list[str]) -> str:
return joiner.join(items) + self.initials_delimiter \
if items else ""
# A fully-empty result renders as "" -- the v1 fallback to
# C.empty_attribute_default (which may be None) is dropped per
# #255.
_s = self.initials_format.format(
first=group(first), middle=group(middle), last=group(last))
return self.collapse_whitespace(_s)
# -- comparison -----------------------------------------------------------
[docs]
def matches(self, other: str | HumanName) -> bool:
"""Component-wise case-insensitive comparison (v1 parity); a
str argument is parsed with this instance's resolved parser."""
if not isinstance(other, (str, HumanName)):
# pre-check so the error names the facade type a caller
# actually passed HumanName.matches(), not the core
# ParsedName it delegates to below
raise TypeError(
f"matches() takes a str or HumanName, got {other!r}")
target = other._parsed if isinstance(other, HumanName) else other
return self._parsed.matches(target, parser=self._resolve())
[docs]
def comparison_key(self) -> tuple[str, ...]:
"""One casefolded component per field in canonical order -- the
v1 replacement for ==/hash (#223); see ParsedName.comparison_key."""
return self._parsed.comparison_key()
# -- dunders ------------------------------------------------------------
def collapse_whitespace(self, string: str) -> str:
# v1 parser.py:976 verbatim, over _render's regexes (the #254
# collapse owns them; this public method keeps v1's narrower
# two-step contract for initials() and direct callers)
string = _render._SPACES.sub(" ", string.strip())
if string and _render._COMMA_CHAR.fullmatch(string[-1]):
string = string[:-1]
return string
def __str__(self) -> str:
if self.string_format is not None:
rendered = self.string_format.format(
**{k: v or "" for k, v in self.as_dict().items()})
# the full #254 collapse is _render._collapse -- one owner
# for the cleanup chain the v1 __str__ spelled inline
return _render._collapse(rendered)
return " ".join(self)
def __repr__(self) -> str:
attrs = (
f" title: {self.title or ''!r}\n"
f" first: {self.first or ''!r}\n"
f" middle: {self.middle or ''!r}\n"
f" last: {self.last or ''!r}\n"
f" suffix: {self.suffix or ''!r}\n"
f" nickname: {self.nickname or ''!r}\n"
f" maiden: {self.maiden or ''!r}"
)
return f"<{self.__class__.__name__} : [\n{attrs}\n]>"
def __iter__(self) -> Iterator[str]:
return (value for member in _MEMBERS
if (value := getattr(self, member)))
def __len__(self) -> int:
return sum(1 for member in _MEMBERS if getattr(self, member))
def __getitem__(self, key: str) -> str:
if isinstance(key, slice):
raise TypeError(
"slicing a HumanName was removed in 2.0 (#258); access "
"the named attributes instead"
)
return getattr(self, key)
[docs]
def as_dict(self, include_empty: bool = True) -> dict[str, str]:
"""The seven v1-named components as a dict; include_empty=False
drops empty fields."""
d = {member: getattr(self, member) for member in _MEMBERS}
if include_empty:
return d
return {k: v for k, v in d.items() if v}
# -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) -----------
def __getstate__(self) -> dict[str, Any]:
# The emitted key set matches v1.4's pickle shape (minus
# encoding/_had_comma/_derived_*, which are v1-internal and
# ignored on read), so one __setstate__ path serves both eras.
state: dict[str, Any] = {
"_full_name": self._full_name,
"original": self.original,
"C": None if self._C is CONSTANTS else self._C,
"string_format": self.string_format,
"initials_format": self.initials_format,
"initials_delimiter": self.initials_delimiter,
"initials_separator": self.initials_separator,
"suffix_delimiter": self.suffix_delimiter,
}
for member in _MEMBERS:
state[f"{member}_list"] = getattr(self, f"{member}_list")
return state
def __setstate__(self, state: dict[str, Any]) -> None:
c = state.get("C")
self._C = CONSTANTS if c is None else c
self._snapshot_gen = -1
defaults = self._C._snapshot()[2]
self._string_format = state.get("string_format",
defaults.string_format)
self._initials_format = state.get("initials_format",
defaults.initials_format)
self._initials_delimiter = state.get("initials_delimiter",
defaults.initials_delimiter)
self._initials_separator = state.get("initials_separator",
defaults.initials_separator)
self._suffix_delimiter = state.get("suffix_delimiter",
defaults.suffix_delimiter)
self._full_name = state.get("_full_name", "")
# Components come back exactly as pickled (spec §2): synthetic
# tokens, never a re-parse. Build them per *_list ENTRY rather
# than from one joined string -- an entry may hold several words
# ("Ph. D.", "Q.C. M.P."), and re-splitting the joined string on
# whitespace would promote each word to its own entry, which the
# suffix view then renders comma-separated ("Ph., D."). Marking
# continuation words "joined" is the inverse of _list_for's heal,
# so list -> pickle -> list is the identity v1 gave us.
tokens: list[Token] = []
for member in _MEMBERS:
role = Role(_V2_FIELD.get(member, member))
entries = state.get(f"{member}_list") or []
# Everything here is iterable but shreds differently: a str
# yields characters ("John" -> first "J o h n"), a Mapping
# yields keys only, bytes yields ints. v1 stored lists, so
# this only guards foreign or hand-built state -- but it
# names the field at the load site instead of failing
# opaquely later, or not at all.
if isinstance(entries, (str, bytes, Mapping)):
raise TypeError(
f"{member}_list must be a list of strings, not "
f"{type(entries).__name__} ({entries!r}); this "
f"pickle was not written by nameparser"
)
for entry in entries:
if not isinstance(entry, str):
raise TypeError(
f"{member}_list entries must be strings, got "
f"{entry!r}; this pickle was not written by "
f"nameparser"
)
for position, word in enumerate(entry.split()):
tokens.append(Token(
word, None, role,
frozenset({"joined"}) if position else frozenset()))
self._parsed = ParsedName(
original=str(state.get("original", "")), tokens=tuple(tokens))