Source code for nameparser._types

"""Core value types for the 2.0 API.

Layering (enforced by tests/v2/test_layering.py): this module imports
nothing from nameparser at module level -- it is the bottom of the
module-import dependency graph. The rendering delegates import _render
and matches() imports _parser at call time; TYPE_CHECKING-only imports
supply the Lexicon/Parser annotations.

Repr policy (applies to every v2 type's __repr__, across this module and
_lexicon.py/_policy.py/_locale.py): bounded output only. No repr may scale
with vocabulary size -- collections render as counts or deltas, never
contents.
"""
from __future__ import annotations

import dataclasses
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum, StrEnum
from typing import TYPE_CHECKING, NamedTuple, NoReturn, TypeVar

if TYPE_CHECKING:
    from nameparser._lexicon import Lexicon
    from nameparser._parser import Parser


[docs] class Role(Enum): """The seven fields of a parsed name, one per :class:`Token`. Declaration order is the canonical field order everywhere (``as_dict()``, ``comparison_key()``, rendering). Pass a member to :meth:`ParsedName.tokens_for`; a member's ``.value`` is the field's string name (``Role.GIVEN.value == "given"``).""" # Declaration order IS the canonical field order (conventions §3): # every listing of the seven fields anywhere derives from this. #: Pre-nominal titles and honorifics ("Dr.", "Sir", "Capt."). TITLE = "title" #: The given (first) name, or its initial. GIVEN = "given" #: Names between given and family -- middle names or initials. MIDDLE = "middle" #: The family (last) name, including any particles ("de la Vega"). FAMILY = "family" #: Post-nominal pieces ("III", "Jr.", "PhD"). SUFFIX = "suffix" #: Delimited nickname content ("Jonathan 'Jack' Kennedy" -> "Jack"). NICKNAME = "nickname" #: A birth surname, from a marker word ("Jane Smith née Jones" -> #: "Jones") or a delimiter pair routed via Policy.maiden_delimiters. MAIDEN = "maiden"
[docs] class Span(NamedTuple): """Where a :class:`Token` came from: a character range into :attr:`ParsedName.original` such that ``original[start:end]`` is the token's source text (``end`` exclusive). A plain two-int NamedTuple; ``None`` in :attr:`Token.span` marks a synthetic token with no source position.""" #: First character index (0-based). start: int #: One past the last character index. end: int def __add__(self, other: object) -> NoReturn: # type: ignore[override] # Inherited tuple + would concatenate two spans into a 4-tuple. # There is deliberately NO covering-span operation: grouping is # index-run based (the anti-#100 invariant: never resolve by # joining text back together) and never merges spans. raise TypeError( "Span does not support +; tuple concatenation is not a " "covering span" )
#: Stable, documented tag vocabulary (API). All other tags are #: namespaced ("vocab:...", "patronymic:...") and unstable. "joined" #: marks a continuation token of a merged piece (the ph-d merge) and #: drives the suffix view's space-vs-comma join. STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"}) #: The one sanctioned view-reorder marker (namespaced = unstable API). #: Tokens cannot reorder (span order is validated), so a role fold that #: must render BEFORE the role's original tokens tags them with this; #: _text_for and the facade lists prepend carriers. Single-sourced here #: so the emitter (_pipeline/_post_rules) and the consumers cannot #: drift. FOLDED_TAG = "vocab:folded-middle" _E = TypeVar("_E", bound=Enum) def _coerce_enum(value: object, enum_cls: type[_E], noun: str, plural: str) -> _E: """Coerce value to enum_cls, or raise the enriched ValueError listing every valid member (enum lookups stay ValueError for any input -- stdlib EnumType precedent, see AGENTS.md's taxonomy rule).""" if isinstance(value, enum_cls): return value try: return enum_cls(value) except ValueError: valid = ", ".join(str(m.value) for m in enum_cls) raise ValueError( f"unknown {noun} {value!r}; valid {plural}: {valid}" ) from None # Pickle support shared by the frozen slots dataclasses: fail at the # LOAD site when a pickle's field layout does not match this version of # the class (version skew) -- silently loading would defer the failure # to a distant attribute read. Values are deliberately NOT re-validated: # pickle is not a security boundary (arbitrary pickles can execute code # anyway), and canonical state only comes from a validated instance. # These are ASSIGNED IN EACH CLASS BODY (not inherited from a mixin): # @dataclass(slots=True) regenerates the class and installs its own # pickle methods unless __getstate__/__setstate__ are in the class's # own __dict__. Lexicon duplicates this logic by design (its slots also # carry a rebuilt mappingproxy) -- layering keeps _lexicon import-free # of _types. def _guarded_getstate(self: object) -> dict[str, object]: fields = dataclasses.fields(self) # type: ignore[arg-type] return {f.name: getattr(self, f.name) for f in fields} def _guarded_setstate(self: object, state: dict[str, object]) -> None: fields = dataclasses.fields(self) # type: ignore[arg-type] expected = {f.name for f in fields} if set(state) != expected: missing = ", ".join(sorted(expected - set(state))) or "none" unexpected = ", ".join(sorted(set(state) - expected)) or "none" raise ValueError( f"incompatible {type(self).__name__} pickle: missing " f"fields: {missing}; unexpected fields: {unexpected}" ) for name, value in state.items(): object.__setattr__(self, name, value)
[docs] @dataclass(frozen=True, slots=True) class Token: """One classified word of a parsed name: its text, where it came from, which field it belongs to, and how it was classified. Read tokens off :attr:`ParsedName.tokens` or :meth:`ParsedName.tokens_for`; you only construct one directly when hand-building a :class:`ParsedName`.""" #: The word exactly as written in the input (never empty). text: str #: Position in ParsedName.original; None marks a synthetic token #: (e.g. introduced by replace()) with no source position. span: Span | None #: The field this token belongs to. role: Role #: Classification labels. Exactly the four in STABLE_TAGS #: ("particle", "conjunction", "initial", "joined") are API; #: namespaced tags like "vocab:..." are unstable debugging #: provenance -- never match against them. tags: frozenset[str] = frozenset() # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: if not isinstance(self.text, str): raise TypeError( f"Token.text must be a str, got {self.text!r}" ) if not self.text: raise ValueError("Token.text must be a non-empty string") object.__setattr__( self, "role", _coerce_enum(self.role, Role, "Role", "roles")) if self.span is not None: if not ( isinstance(self.span, tuple) and len(self.span) == 2 # bool is an int subclass: (False, True) is a comparison # result leaking into a coordinate slot, not a span and all(isinstance(v, int) and not isinstance(v, bool) for v in self.span) ): raise TypeError( f"invalid span {self.span!r}: expected a (start, end) " "pair of ints or None" ) start, end = self.span if start < 0 or end < start: raise ValueError( f"invalid span ({start}, {end}): need 0 <= start <= end" ) object.__setattr__(self, "span", Span(start, end)) # The same guards _normset applies to Lexicon vocabulary: a bare # string would become its character set, a mapping would silently # contribute only its keys. if isinstance(self.tags, str): raise TypeError( "Token.tags must be an iterable of strings, " "not a bare string" ) if isinstance(self.tags, Mapping): raise TypeError( "Token.tags must be an iterable of strings, not a mapping" ) tags = frozenset(self.tags) for tag in tags: if not isinstance(tag, str): raise TypeError( f"Token.tags must contain only strings, got {tag!r}" ) object.__setattr__(self, "tags", tags) def __repr__(self) -> str: # Bounded output: a single token's text/span/role/tags, never # scales with vocabulary size (design rule -- see module docstring). where = (f"@{self.span.start}:{self.span.end}" if self.span is not None else "@synthetic") tags = f" {{{', '.join(sorted(self.tags))}}}" if self.tags else "" return f"Token({self.text!r} {where} {self.role.name}{tags})"
[docs] class AmbiguityKind(StrEnum): """The stable vocabulary of :class:`Ambiguity` kinds. A StrEnum: members ARE their string values, so ``kind == "particle-or-given"`` compares directly. New kinds may be added in minor releases; existing values never change meaning. A kind names a FORK THE PARSE HAD TO CALL, not a word that could be read two ways: the same token elsewhere in a name may present no choice at all and is then reported by nothing. Reporting is also partial -- a kind listed here is not necessarily emitted everywhere its fork occurs (the comma paths stay quiet by design), and coverage grows over releases. A non-empty tuple is a signal to act on; an empty one is not a guarantee of certainty.""" #: Reserved: the name's field order itself is uncertain (e.g. a #: two-word name under a non-default name_order). Not yet emitted; #: planned for 2.x. ORDER = "order" #: Delimited content is an ambiguous suffix acronym, so it reads #: plausibly as either a post-nominal or a nickname -- "JEFFREY #: (JD) BRICKEN" keeps the nickname reading, where the #: unambiguous "(MBA)" escapes to suffix on vocabulary alone. SUFFIX_OR_NICKNAME = "suffix-or-nickname" #: A trailing word reads plausibly as either a post-nominal or an #: ordinary name part. Covers an ambiguous acronym written without #: periods ("John Smith MA" takes MA as a credential because a #: family name remains; "Jack MA" keeps it as the name because none #: would) and a trailing roman numeral, which is a suffix where any #: other single letter would be a name ("John Smith V" vs "John #: Smith B"). Which name part was declined depends on position and #: ``name_order``, so ``detail`` names it rather than the kind. SUFFIX_OR_NAME = "suffix-or-name" #: A leading ambiguous particle was read as a given name -- "Van #: Johnson" parses given="Van", but "Van" is also a family-name #: particle in other names. PARTICLE_OR_GIVEN = "particle-or-given" #: A nickname/maiden delimiter opened without closing (or closed #: without opening); the text was kept as literal name content, so #: the tokens are the one the stray character ended up inside. #: Two cases leave that tuple empty: a character that lands in no #: token at all (inside a masked region), and an input with no #: alphanumeric content anywhere, which parses to an empty name -- #: the report survives because "was this malformed?" is the only #: question left, but there is no token for it to point at. #: ``parse("(")`` is the second case, not an exotic one. UNBALANCED_DELIMITER = "unbalanced-delimiter" #: More comma-separated segments than any recognized name shape; #: the parse is best-effort over the extra segments. COMMA_STRUCTURE = "comma-structure"
[docs] @dataclass(frozen=True, slots=True) class Ambiguity: """A call the parser made that could legitimately have gone the other way, surfaced on :attr:`ParsedName.ambiguities` instead of silently guessed away. The parse still commits to one reading -- an Ambiguity is a flag for review, not an error.""" #: Which known ambiguity shape this is (stable API values). kind: AmbiguityKind #: Human-readable specifics of this occurrence (wording unstable). detail: str #: The tokens involved -- always a value-equal subset of the owning #: ParsedName's tokens (checked with ==, not identity: two distinct #: Token instances with identical text/span/role/tags satisfy this); #: may be empty (e.g. unbalanced-delimiter). tokens: tuple[Token, ...] # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: object.__setattr__( self, "kind", _coerce_enum(self.kind, AmbiguityKind, "AmbiguityKind", "kinds")) if not isinstance(self.detail, str): raise TypeError( f"Ambiguity.detail must be a str, got {self.detail!r}" ) if not self.detail: raise ValueError("Ambiguity.detail must be a non-empty string") toks = tuple(self.tokens) for tok in toks: if not isinstance(tok, Token): raise TypeError( f"Ambiguity.tokens must contain only Token instances, " f"got {tok!r}" ) object.__setattr__(self, "tokens", toks) def __repr__(self) -> str: texts = "/".join(repr(t.text) for t in self.tokens) return f"Ambiguity({self.kind.value!r}: {texts})"
[docs] @dataclass(frozen=True, slots=True) class ParsedName: """The immutable result of parsing one name string. Read the seven fields as strings (``.given``, ``.family``, ...); inspect structure through :attr:`tokens` / :meth:`tokens_for`; correct a parse with :meth:`replace` (returns a new value); produce output with :meth:`render`, :meth:`initials`, :meth:`capitalized`, or ``str()``. Constructor-enforced invariants: spans ascending, non-overlapping, in bounds of `original`; every Ambiguity's tokens are a value-equal subset of `tokens` (see Ambiguity.tokens). Provenance semantics (text == original[span] for parser-produced names) are documented, not enforced -- transforms like replace() legitimately break them. """ #: The input string exactly as passed to parse(). original: str #: Every classified token, in document order. tokens: tuple[Token, ...] #: Judgment calls that could have gone the other way; empty for #: most names (see Ambiguity). ambiguities: tuple[Ambiguity, ...] = () # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: if not isinstance(self.original, str): raise TypeError( f"ParsedName.original must be a str, got {self.original!r}" ) object.__setattr__(self, "tokens", tuple(self.tokens)) object.__setattr__(self, "ambiguities", tuple(self.ambiguities)) for tok in self.tokens: if not isinstance(tok, Token): raise TypeError( f"ParsedName.tokens must contain only Token instances, " f"got {tok!r}" ) for amb in self.ambiguities: if not isinstance(amb, Ambiguity): raise TypeError( f"ParsedName.ambiguities must contain only Ambiguity " f"instances, got {amb!r}" ) prev_end = 0 for tok in self.tokens: if tok.span is None: continue if tok.span.end > len(self.original): raise ValueError( f"token {tok.text!r} span {tuple(tok.span)} is out of " f"bounds for original of length {len(self.original)}" ) if tok.span.start < prev_end: raise ValueError( f"token spans must be ascending and non-overlapping; " f"token {tok.text!r} at {tuple(tok.span)} begins before " f"offset {prev_end}" ) prev_end = tok.span.end # Hash once rather than rescanning the tuple per referenced # token: a name can carry an ambiguity per token (a string of # stray delimiters does), and the linear form made construction # quadratic in their product. Set membership uses the same value # equality the tuple scan did -- Token is frozen and hashable. if self.ambiguities: known = set(self.tokens) for amb in self.ambiguities: for tok in amb.tokens: # membership is by Token's value equality, not # identity: this only guarantees a value-equal token # exists in self.tokens, not that `tok` IS one of # those objects. if tok not in known: raise ValueError( f"Ambiguity token {tok.text!r} is not a " f"subset of this ParsedName's tokens" ) def __bool__(self) -> bool: return bool(self.tokens) def __str__(self) -> str: return self.render() def __repr__(self) -> str: # 4-space indent, matching HumanName's repr (v1 style) lines = [] for role in Role: text = self._text_for(role) if text: lines.append(f" {role.value}: {text!r}") if self.ambiguities: kinds = [a.kind.value for a in self.ambiguities] lines.append(f" ambiguities: {kinds!r}") body = "\n".join(lines) return f"<ParsedName: [\n{body}\n]>" if lines else "<ParsedName: []>" # -- string views (canonical order = Role declaration order) -------- def _text_for(self, *roles: Role, tag: str | None = None, without_tag: str | None = None) -> str: suffix_join = roles == (Role.SUFFIX,) parts: list[str] = [] folded: list[str] = [] for tok in self.tokens: if tok.role not in roles: continue if tag is not None and tag not in tok.tags: continue if without_tag is not None and without_tag in tok.tags: continue # "joined" (stable tag) marks a continuation of the previous # token ("Ph." + "D."): attach with a space so the suffix # view's ", " join does not split one credential in two if suffix_join and "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; spans cannot reorder, so the view does folded.append(tok.text) else: parts.append(tok.text) return (", " if suffix_join else " ").join(folded + parts) @property def title(self) -> str: return self._text_for(Role.TITLE) @property def given(self) -> str: return self._text_for(Role.GIVEN) @property def middle(self) -> str: return self._text_for(Role.MIDDLE) @property def family(self) -> str: return self._text_for(Role.FAMILY) @property def suffix(self) -> str: return self._text_for(Role.SUFFIX) @property def nickname(self) -> str: return self._text_for(Role.NICKNAME) @property def maiden(self) -> str: return self._text_for(Role.MAIDEN) # -- derived views (filters over roles + STABLE tags only) ---------- @property def family_particles(self) -> str: return self._text_for(Role.FAMILY, tag="particle") @property def family_base(self) -> str: return self._text_for(Role.FAMILY, without_tag="particle") @property def surnames(self) -> str: return self._text_for(Role.MIDDLE, Role.FAMILY) @property def given_names(self) -> str: return self._text_for(Role.GIVEN, Role.MIDDLE) # -- structured access ---------------------------------------------- def tokens_for(self, role: Role) -> tuple[Token, ...]: return tuple(t for t in self.tokens if t.role is role) def as_dict(self, include_empty: bool = True) -> dict[str, str]: # _text_for handles the suffix ", "-join (single-role SUFFIX call) d = {role.value: self._text_for(role) for role in Role} if not include_empty: d = {k: v for k, v in d.items() if v} return d # -- editing ----------------------------------------------------------
[docs] def replace(self, **fields: str) -> ParsedName: """Return a new ParsedName with the named fields re-tokenized as synthetic tokens (span=None). Whitespace-splits each value; an empty value clears the field. original is unchanged (provenance). Ambiguities referencing replaced tokens are dropped. """ by_value = {role.value: role for role in Role} for key, value in fields.items(): if key not in by_value: raise TypeError( f"unknown field {key!r}; expected one of " f"{', '.join(by_value)}" ) if not isinstance(value, str): raise TypeError( f"field {key!r} must be a str, got {value!r}" ) def synthetic(value: str, role: Role) -> list[Token]: return [Token(word, None, role) for word in value.split()] replaced = {by_value[k]: v for k, v in fields.items()} new_tokens: list[Token] = [] emitted: set[Role] = set() for tok in self.tokens: if tok.role in replaced: if tok.role not in emitted: new_tokens.extend(synthetic(replaced[tok.role], tok.role)) emitted.add(tok.role) continue new_tokens.append(tok) for role in Role: if role in replaced and role not in emitted: new_tokens.extend(synthetic(replaced[role], role)) kept = tuple( amb for amb in self.ambiguities if all(t in new_tokens for t in amb.tokens) ) return ParsedName(self.original, tuple(new_tokens), kept)
# -- comparison -------------------------------------------------------
[docs] def comparison_key(self) -> tuple[str, ...]: """One casefolded component per Role, in canonical order, for dedup, dict keys, and sorting. The semantic layer; __eq__ stays strict. """ return tuple(self._text_for(role).casefold() for role in Role)
[docs] def matches(self, other: str | ParsedName, *, parser: Parser | None = None) -> bool: """Component-wise case-insensitive comparison (the semantic layer; __eq__ stays strict). A str argument is parsed with `parser`, or the default parser when None.""" if isinstance(other, str): import nameparser._parser as _parser active = parser if parser is not None else _parser._default_parser() other = active.parse(other) if not isinstance(other, ParsedName): raise TypeError( f"matches() takes a str or ParsedName, got {other!r}") return self.comparison_key() == other.comparison_key()
# -- rendering delegates ---------------------------------------------- # One-line delegation to nameparser._render: parsing code physically # cannot import formatting logic (layering rule, enforced by # tests/v2/test_layering.py), so these import at call time -- module # level stays internal-import-free.
[docs] def render( self, spec: str = ('{title} {given} "{nickname}" {middle} {family} ' "({maiden}) {suffix}"), ) -> str: """Fill the str.format spec from the seven role fields and the derived views; empty fields collapse (#254), including the default spec's decorations (empty '""' and '()' wrappers). The default shows every non-empty field: the nickname quoted after the given name, the maiden name parenthesized after the family name. Unknown keys raise KeyError naming the valid fields.""" import nameparser._render as _render return _render.render(self, spec)
[docs] def initials(self, spec: str = "{given} {middle} {family}", delimiter: str = ".", separator: str = " ") -> str: """Initials per group; v1's initials_format/_delimiter/_separator become call-site arguments instead of Config-wide settings. Valid spec keys: given, middle, family.""" import nameparser._render as _render return _render.initials(self, spec, delimiter, separator)
[docs] def capitalized(self, lexicon: Lexicon | None = None, *, force: bool = False) -> ParsedName: """Case-fixing transform -> new ParsedName, same spans, new token texts. Needs a lexicon for capitalization_exceptions and particle rules; None uses the default lexicon. force=False preserves mixed-case input (v1 parity). Idempotent.""" import nameparser._render as _render return _render.capitalized(self, lexicon, force=force)