"""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 Callable, 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(StrEnum):
"""The seven fields of a parsed name, one per :class:`Token`.
Declaration order is the canonical field order everywhere
(``as_dict()``, ``comparison_key()``, rendering). A StrEnum, like
:class:`AmbiguityKind`: members ARE their string values, so
``token.role == "given"`` compares directly and
``str(Role.GIVEN) == "given"``. Members order as strings, so
``sorted()`` yields alphabetical order -- iterate ``Role`` itself
for the canonical order."""
# Declaration order IS the canonical field order:
# 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"
)
#: The four :attr:`Token.tags` values that are stable API.
#: "particle" marks a word from the particle vocabulary ("de", "van")
#: wherever it lands -- including a given-name "Van" -- so combine it
#: with Role.FAMILY to get particle-vocabulary family words -- but
#: NOT to reproduce `family_particles`, which since #404 also consults
#: UNJOINED_TAG and excludes a particle standing alone in its part
#: ("Anh Do" has a particle-tagged family word and no family
#: particles); "conjunction" a joining word ("and", "y"); "initial" an
#: initial-shaped word in a script that HAS initials -- "J." or "А.",
#: never "씨." (#320);
#: "joined" a continuation of the previous token within one merged
#: piece ("Ph." + "D."), which the suffix view joins with a space
#: instead of ", ". Every other tag is namespaced ("vocab:...") and is
#: unstable debugging provenance -- never match against those.
#: This prose is the hand-maintained twin of docs/modules.rst's
#: STABLE_TAGS block; nothing pins the two against each other (the
#: test only compares the frozenset), so edit both or neither.
STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"})
#: A name part whose every word is particle vocabulary is a part
#: where none of them is doing a particle's work -- nothing joins them
#: to a name -- so THREE views read them as ordinary name words: they
#: anchor `family_base`, drop out of `family_particles`, and
#: contribute initials (rules.md#R2). Capitalization does not consult
#: the mark and still lowercases them, which #407 tracks.
#: MARKED rather than untagged: `particle` is stable API and says the
#: word IS particle vocabulary wherever it lands, which stays true, and
#: keeping it leaves a later rule free to report the fork this decides.
UNJOINED_TAG = "vocab:unjoined-particle"
#: 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 members of
#: :data:`~nameparser.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]
@dataclass(frozen=True, slots=True)
class Segmentation:
"""A segmenter's answer for one unspaced token: the interior offsets
to split at (each offset begins a new piece, so
``Segmentation((2,))`` cuts a three-character token into
``token[:2]`` and ``token[2:]``; strictly ascending, each >= 1 -- an
index protocol, so a segmenter physically cannot invent, drop, or
rewrite characters) and an optional confidence in [0, 1].
``Segmentation(())`` means "confidently one token" -- distinct from
returning None, which DECLINES ("I don't know"). The upper bound
(< len(token)) is the half this class cannot check, never having
seen the text; the consuming stage checks it and RAISES
``ValueError`` on a violation, the same call it makes on an answer
of the wrong type -- both are protocol bugs in the segmenter, not
facts about the name."""
#: Interior character offsets to split at, ascending.
splits: tuple[int, ...]
#: How sure the segmenter is, or None for "no opinion".
confidence: float | None = None
# in the class body so @dataclass(slots=True) keeps them
__getstate__ = _guarded_getstate
__setstate__ = _guarded_setstate
def __post_init__(self) -> None:
# Both guards Token.tags carries, for the same two reasons and
# a third of this field's own: a bare string is iterable, so
# Segmentation("") would sail through as "confidently one
# token" -- an opinion nobody stated -- and Segmentation("23")
# would fail one character deep naming '2' rather than the
# argument. A mapping would silently contribute only its keys,
# and a bare int would surface as an uncurated "not iterable"
# from the tuple() below.
if isinstance(self.splits, str):
raise TypeError(
"Segmentation.splits must be an iterable of integers, "
"not a bare string"
)
if isinstance(self.splits, Mapping):
raise TypeError(
"Segmentation.splits must be an iterable of integers, "
"not a mapping"
)
try:
iter(self.splits)
except TypeError:
raise TypeError(
f"Segmentation.splits must be an iterable of integers, "
f"got {self.splits!r}"
) from None
# OUTSIDE the try on purpose: iter() cannot run a generator's
# body, but tuple() can, and a TypeError raised in there is the
# caller's own bug -- relabeling it "must be an iterable" would
# send the reader to the wrong place entirely.
splits = tuple(self.splits)
for offset in splits:
# bool is an int subclass: True as an offset is a comparison
# result leaking into an index slot, not a split point
if isinstance(offset, bool) or not isinstance(offset, int):
raise TypeError(
f"Segmentation.splits must be integers, got {offset!r}")
if offset < 1:
raise ValueError(
f"Segmentation.splits must be interior offsets "
f"(each >= 1), got {offset}")
if any(b <= a for a, b in zip(splits, splits[1:])):
raise ValueError(
f"Segmentation.splits must be strictly ascending, "
f"got {splits!r}")
object.__setattr__(self, "splits", splits)
conf = self.confidence
if conf is not None:
if isinstance(conf, bool) or not isinstance(conf, (int, float)):
raise TypeError(
f"Segmentation.confidence must be a float or None, "
f"got {conf!r}")
# stored as given, not coerced to float: an int 1 is a valid
# confidence and the range check is what the callers rely on
if not 0.0 <= conf <= 1.0:
raise ValueError(
f"Segmentation.confidence must be within [0, 1], "
f"got {conf!r}")
#: The segmenter hook's shape: token text in, :class:`Segmentation` out,
#: None to decline. Plug one in via ``Parser(segmenter=...)``.
Segmenter = Callable[[str], Segmentation | None]
[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"
#: An ambiguous particle at the head of a name is either a
#: particle or a name in its own right -- "Van Johnson" is the
#: actor's given name, a bare "Van Buren" the presidential
#: surname, and the two-word shape cannot distinguish them. Two
#: shapes report this kind, decided in different stages, and
#: ``detail`` is what tells them apart. A particle left standing
#: alone chained nothing and was assigned a role, which ``detail``
#: names ("read as a given name") -- that role is whatever
#: assignment gave it, so it follows ``name_order`` and any
#: ``script_orders`` entry, which is why the kind cannot name it.
#: A particle that something ahead of it shifted off the front of
#: the name was instead claimed by the prefix chain, and ``detail``
#: says that and names no field at all: grouping runs before roles
#: exist, so that text is the same under every order. Since #367 a
#: plain title is not such a thing -- "Dr. Van Johnson" reads as
#: the untitled "Van Johnson" does and takes the first shape --
#: and what remains is a leading word that is both a title and a
#: particle, so it stays a name piece and the particle behind it is
#: genuinely not leading ("Freiherr von Richthofen").
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"
#: A division of an unspaced CJK token that the parse had to
#: choose, from either of the two things that can divide one.
#: A VOCABULARY fork: more than one surname-supported split
#: existed ("夏侯惇" was taken as 夏侯 + 惇, while 夏 + 侯惇 also
#: matched), longest-match picked, and ``detail`` names both
#: readings (#271). Or a SEGMENTER answer scoring under the
#: stage's confidence floor: only one reading was offered, but the
#: score says it was a statistical guess rather than a stated
#: certainty, and ``detail`` names the pieces and the score
#: (#272). Either way it points at ALL the tokens the division
#: produced -- two for a vocabulary split, n+1 for a segmenter
#: answer cutting n times.
SEGMENTATION = "segmentation"
[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})"
def _validated_field_strings(fields: dict[str, str]) -> dict[Role, str]:
"""Shared by ParsedName.replace and Parser.revise: validate a
**fields mapping of role names to replacement strings and key it
by Role. TypeErrors match replace()'s historical wording."""
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}"
)
return {by_value[k]: v for k, v in fields.items()}
def _remarked(tokens: list[Token]) -> tuple[Token, ...]:
"""UNJOINED_TAG recomputed over an edited token list.
The mark says a particle stands ALONE in its part, which is a fact
about the part rather than the word, so an edit that re-roles
tokens invalidates it in both directions: replace()/revise() splice
a sub-parse's tokens into one field, and a particle marked alone
there can land beside a name word (stale mark) while an unmarked
one can end up alone (missing mark). Parser.revise strips
FOLDED_TAG for the same reason; this one is RECOMPUTED rather than
stripped, because absent is only correct for half the cases.
"""
out = list(tokens)
for role in (Role.GIVEN, Role.MIDDLE, Role.FAMILY):
part = [i for i, t in enumerate(out) if t.role is role]
alone = bool(part) and all("particle" in out[i].tags for i in part)
for i in part:
tags = out[i].tags
if alone and UNJOINED_TAG not in tags:
out[i] = dataclasses.replace(out[i], tags=tags | {UNJOINED_TAG})
elif not alone and UNJOINED_TAG in tags:
out[i] = dataclasses.replace(out[i],
tags=tags - {UNJOINED_TAG})
return tuple(out)
[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; Parser.revise is the
tag-preserving form); 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,
unless_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
# A token carrying `unless_tag` is read as though it did
# not carry `tag`/`without_tag` at all -- so it is EXCLUDED
# by a `tag=` filter and INCLUDED by a `without_tag=` one,
# which is how an unjoined particle anchors the base and
# leaves the particles view.
waived = unless_tag is not None and unless_tag in tok.tags
if tag is not None and (tag not in tok.tags or waived):
continue
if without_tag is not None and without_tag in tok.tags \
and not waived:
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
# rules.md#R1: "every field is a view computed from the parsed
# words at read time, joining its words in written order — except
# folded family words" (O3's fold and P6's tussenvoegsel, which
# render before the rest of the family)
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
# rules.md#R2: "the family name splits into further views: the
# base (the family without its leading particles) and the
# particles themselves"
def family_particles(self) -> str:
return self._text_for(Role.FAMILY, tag="particle",
unless_tag=UNJOINED_TAG)
@property
def family_base(self) -> str:
return self._text_for(Role.FAMILY, without_tag="particle",
unless_tag=UNJOINED_TAG)
@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 ----------------------------------------------
[docs]
def tokens_for(self, role: Role | str) -> tuple[Token, ...]:
"""The tokens of one field, in document order. Takes a Role
member or its string value; anything else raises ValueError
naming the valid roles."""
role = _coerce_enum(role, Role, "Role", "roles")
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.
Replacement tokens carry NO tags, so tag-driven views degrade:
family_particles empties, particles regain their initials, and
a multi-word suffix is comma-joined. Parser.revise() is the
tag-preserving alternative.
"""
replaced = _validated_field_strings(fields)
synthetic = {
role: tuple(Token(word, None, role) for word in value.split())
for role, value in replaced.items()
}
return self._with_field_tokens(synthetic)
def _with_field_tokens(
self, replaced: Mapping[Role, tuple[Token, ...]],
) -> ParsedName:
"""Shared tail of replace()/Parser.revise(): splice each role's
replacement tokens in at the role's first position (appended in
canonical order when the role had no tokens); drop ambiguities
whose referents were replaced."""
# Private contract, made self-enforcing: a token filed under a
# key that is not its own role is the one way this shared tail
# could build a semantically wrong ParsedName (the splice keys
# on the mapping, the views key on the token).
for role, toks in replaced.items():
for tok in toks:
if tok.role is not role:
raise ValueError(
f"replacement token {tok.text!r} has role "
f"{tok.role.value}, not {role.value}"
)
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(replaced[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(replaced[role])
kept = tuple(
amb for amb in self.ambiguities
if all(t in new_tokens for t in amb.tokens)
)
return ParsedName(self.original, _remarked(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 with the DEFAULT parser when None -- if this name
came from a custom Parser, pass that parser (or use
Parser.matches); otherwise the comparison silently runs under
the wrong configuration."""
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 -- if this name
came from a custom Parser, pass its lexicon or use
Parser.capitalized. force=False preserves mixed-case input
(v1 parity). Idempotent."""
import nameparser._render as _render
return _render.capitalized(self, lexicon, force=force)