Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_facade.py: 51%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""The 2.0 ``HumanName`` facade (mechanisms.md#FACADE-CONTRACT): a
2mutable wrapper
3over a frozen ParsedName, delegating parsing to the core Parser resolved
4from the bound Constants shim. Keeps every v1 spelling. Deleted in 3.0.
6Layering: facade layer -- may import anything public plus _render.
7"""
8from __future__ import annotations
10import dataclasses
11import warnings
12from collections.abc import Iterator, Mapping
13from typing import Any
15# Import order matters here -- breaks a real import cycle. nameparser.
16# config's package __init__ re-exports CONSTANTS/Constants/etc. from
17# _config_shim (the v1 nameparser.config.Constants compat path), while
18# _config_shim's own default CONSTANTS singleton needs nameparser.config's
19# DATA submodules (titles, prefixes, ...), imported lazily -- see
20# _config_shim.py's module docstring. If _config_shim were the first of
21# the two ever touched, building its CONSTANTS would need to import
22# nameparser.config, whose __init__ would in turn need _config_shim's
23# (not-yet-built) CONSTANTS: ImportError. Importing the config package
24# here first lets its __init__ run to completion; when IT then imports
25# _config_shim to build the default CONSTANTS, nameparser.config is
26# already registered in sys.modules, so its data-submodule imports
27# resolve directly instead of re-entering (and failing on) its own
28# still-executing __init__.
29import nameparser.config # noqa: F401
31import nameparser._render as _render
32from nameparser._config_shim import CONSTANTS, Constants, _cached_parser
33from nameparser._lexicon import _normalize
34from nameparser._parser import Parser
35from nameparser._types import (FOLDED_TAG, UNCLASSIFIED_TAG, ParsedName,
36 Role, Token)
38_V2_FIELD = {"first": "given", "last": "family"} # v1 name -> v2 name
39_V1_SPELLING = {v2: v1 for v1, v2 in _V2_FIELD.items()}
40# derived from Role: declaration order IS the canonical field order
41# (never restated), rendered in the v1 spellings
42_MEMBERS = tuple(_V1_SPELLING.get(r.value, r.value) for r in Role)
46#: v1 parsing hooks the facade never calls
47#: (mechanisms.md#FACADE-CONTRACT / #280).
48_V1_HOOKS = (
49 "pre_process", "post_process", "parse_full_name", "parse_pieces",
50 "parse_nicknames", "join_on_conjunctions", "squash_emoji",
51 "handle_firstnames", "handle_middle_name_as_last",
52 "is_title", "is_conjunction", "is_prefix", "is_roman_numeral",
53 "is_suffix", "is_suffix_lenient", "is_an_initial",
54)
55# Module-level mutable state (sanctioned exception, AGENTS.md "One
56# sanctioned global"): strong-references every distinct HumanName
57# subclass for process lifetime so the hook warning fires once per
58# class. Fine in practice -- subclasses are statically defined, and the
59# whole module is deleted with the facade layer in 3.0.
60_WARNED_SUBCLASSES: set[type] = set()
63def _empty_parsed() -> ParsedName:
64 return ParsedName(original="", tokens=(), ambiguities=())
67class HumanName:
68 """v1 ``HumanName`` facade: a mutable wrapper over a frozen
69 ``ParsedName``, delegating all parsing to the core ``Parser``
70 resolved from the bound ``Constants`` shim (dirty-tracked via its
71 ``_generation``). Keeps every v1 spelling (``first``/``last`` over
72 the core ``given``/``family``); the v1 parsing hooks are never
73 called (#280). Deleted with the facade layer in 3.0.
74 """
76 def __init__(
77 self,
78 full_name: str = "",
79 constants: Constants | None = CONSTANTS,
80 string_format: str | None = None,
81 initials_format: str | None = None,
82 initials_delimiter: str | None = None,
83 initials_separator: str | None = None,
84 suffix_delimiter: str | None = None,
85 first: str | list[str] | None = None,
86 middle: str | list[str] | None = None,
87 last: str | list[str] | None = None,
88 title: str | list[str] | None = None,
89 suffix: str | list[str] | None = None,
90 nickname: str | list[str] | None = None,
91 maiden: str | list[str] | None = None,
92 ) -> None:
93 if constants is None:
94 raise TypeError(
95 "constants=None was removed in 2.0 (#261): pass a "
96 "Constants instance, or use the new Parser/Lexicon/"
97 "Policy API for per-call configuration"
98 )
99 if not isinstance(constants, Constants):
100 raise TypeError(
101 f"constants must be a Constants instance, got {constants!r}"
102 )
103 self._warn_overridden_hooks()
104 self._C = constants
105 self._snapshot_gen = -1 # forces first resolve
106 _, _, defaults = constants._snapshot()
107 self.string_format = (string_format if string_format is not None
108 else defaults.string_format)
109 self.initials_format = (initials_format if initials_format is not None
110 else defaults.initials_format)
111 self.initials_delimiter = (
112 initials_delimiter if initials_delimiter is not None
113 else defaults.initials_delimiter)
114 self.initials_separator = (
115 initials_separator if initials_separator is not None
116 else defaults.initials_separator)
117 # These five assignments route through the validating properties
118 # below. The suffix_delimiter setter resets _snapshot_gen to -1
119 # on every assignment (including this one, harmlessly -- it's
120 # already -1 above), so reassigning it post-construction (e.g.
121 # n.suffix_delimiter = " - ") correctly forces the next
122 # _resolve() to rebuild the Policy with the new delimiter.
123 self.suffix_delimiter = (suffix_delimiter if suffix_delimiter is not None
124 else defaults.suffix_delimiter)
125 self._full_name = ""
126 self._parsed = _empty_parsed()
127 if first or middle or last or title or suffix or nickname or maiden:
128 # These route through the field-setter properties (None
129 # clears the field); no full-string parse, full_name stays "".
130 self.first = first
131 self.middle = middle
132 self.last = last
133 self.title = title
134 self.suffix = suffix
135 self.nickname = nickname
136 self.maiden = maiden
137 else:
138 self._apply_full_name(full_name)
140 @classmethod
141 def _warn_overridden_hooks(cls) -> None:
142 if cls is HumanName or cls in _WARNED_SUBCLASSES:
143 return
144 overridden = [h for h in _V1_HOOKS
145 if getattr(cls, h, None) is not getattr(
146 HumanName, h, None)]
147 _WARNED_SUBCLASSES.add(cls)
148 if overridden:
149 warnings.warn(
150 f"{cls.__name__} overrides v1 parsing hooks "
151 f"({', '.join(overridden)}) that the 2.0 facade never "
152 f"calls; parsing is delegated to the core Parser. "
153 f"Migrate to the Lexicon/Policy API. See "
154 f"https://github.com/derek73/python-nameparser/issues/280",
155 DeprecationWarning, stacklevel=3)
157 # -- render defaults -----------------------------------------------------
158 # One-line validating setters (mechanisms.md#FACADE-CONTRACT):
159 # assigning a non-str (or, for
160 # the two fields that allow it, non-str-non-None) raises TypeError at
161 # assignment time instead of failing later inside .format().
163 @property
164 def string_format(self) -> str | None:
165 return self._string_format
167 @string_format.setter
168 def string_format(self, value: str | None) -> None:
169 if value is not None and not isinstance(value, str):
170 raise TypeError(
171 f"string_format must be a str or None, got {value!r}")
172 self._string_format = value
174 @property
175 def initials_format(self) -> str:
176 return self._initials_format
178 @initials_format.setter
179 def initials_format(self, value: str) -> None:
180 if not isinstance(value, str):
181 raise TypeError(
182 f"initials_format must be a str, got {value!r}")
183 self._initials_format = value
185 @property
186 def initials_delimiter(self) -> str:
187 return self._initials_delimiter
189 @initials_delimiter.setter
190 def initials_delimiter(self, value: str) -> None:
191 if not isinstance(value, str):
192 raise TypeError(
193 f"initials_delimiter must be a str, got {value!r}")
194 self._initials_delimiter = value
196 @property
197 def initials_separator(self) -> str:
198 return self._initials_separator
200 @initials_separator.setter
201 def initials_separator(self, value: str) -> None:
202 if not isinstance(value, str):
203 raise TypeError(
204 f"initials_separator must be a str, got {value!r}")
205 self._initials_separator = value
207 @property
208 def suffix_delimiter(self) -> str | None:
209 return self._suffix_delimiter
211 @suffix_delimiter.setter
212 def suffix_delimiter(self, value: str | None) -> None:
213 if value is not None and not isinstance(value, str):
214 raise TypeError(
215 f"suffix_delimiter must be a str or None, got {value!r}")
216 self._suffix_delimiter = value
217 # Invalidate the cached Policy: _resolve() layers suffix_delimiter
218 # onto extra_suffix_delimiters, so a stale snapshot would keep
219 # parsing against the old delimiter.
220 self._snapshot_gen = -1
222 # -- config / parsing ---------------------------------------------------
224 def _resolve(self) -> Parser:
225 """Dirty-tracked parser resolution
226 (mechanisms.md#CONFIG-SHIM-SNAPSHOT): rebuild the
227 snapshot only when the bound Constants' generation moved."""
228 gen = self._C._generation
229 if self._snapshot_gen != gen:
230 lexicon, policy, _ = self._C._snapshot()
231 if self.suffix_delimiter:
232 policy = dataclasses.replace(
233 policy,
234 extra_suffix_delimiters=frozenset(
235 {self.suffix_delimiter}))
236 self._lexicon, self._policy = lexicon, policy
237 self._parser = _cached_parser(lexicon, policy)
238 self._snapshot_gen = gen
239 # the fast path is a plain attribute return: hashing the two
240 # value objects for the lru lookup is the whole fast-path cost
241 return self._parser
243 def parse_full_name(self) -> None:
244 """Re-parse the stored ``full_name`` (v1's documented re-parse
245 trigger, docs/customize.rst): mutate ``name.C`` then call this to
246 force a re-parse without reassigning ``full_name``. The v1
247 parsing INTERNALS this name evokes live in the core ``Parser``,
248 not here; a subclass overriding this method still triggers the
249 #280 hook-override warning, and full_name assignment never
250 consults it."""
251 self._apply_full_name(self._full_name)
253 def _apply_full_name(self, value: str) -> None:
254 if isinstance(value, bytes):
255 raise TypeError(
256 "bytes input was removed in 2.0 (#245): decode first, "
257 "e.g. HumanName(raw.decode('utf-8'))"
258 )
259 if not isinstance(value, str):
260 raise TypeError(f"full_name must be a str, got {value!r}")
261 # parse FIRST: if snapshot resolution raises, the instance must
262 # not be left with a new full_name over the old parsed fields
263 parsed = self._resolve().parse(value)
264 self._full_name = value
265 self._parsed = parsed
266 if self._C.capitalize_name:
267 self.capitalize() # v1 parser.py:1653 parity
269 def capitalize(self, force: bool | None = None) -> None:
270 """Re-capitalize the current parse against the bound lexicon.
271 force=None reads the bound Constants' render default
272 (force_mixed_case_capitalization); the core's capitalized()
273 implements the single-case gate (v1 parity) -- not
274 re-implemented here."""
275 self._resolve()
276 if force is None:
277 force = self._C.force_mixed_case_capitalization
278 self._parsed = self._parsed.capitalized(self._lexicon, force=force)
280 @property
281 def full_name(self) -> str:
282 return self._full_name
284 @full_name.setter
285 def full_name(self, value: str) -> None:
286 self._apply_full_name(value)
288 @property
289 def original(self) -> str:
290 return self._parsed.original or self._full_name
292 @property
293 def C(self) -> Constants:
294 return self._C
296 @C.setter
297 def C(self, constants: Constants | None) -> None:
298 # v1.4 closed #239 by making C a validating setter that ONLY
299 # stores the new value -- no re-parse (checked against v1.4:
300 # `git show 2d5d8c2:nameparser/parser.py` lines ~204-206, the C
301 # setter body is exactly `self._C = self._validate_constants(...)`).
302 # A caller who wants the new config reflected must still trigger
303 # a re-parse, e.g. via parse_full_name() or a full_name
304 # reassignment -- matched here rather than re-parsing eagerly.
305 if constants is None:
306 raise TypeError(
307 "assigning constants=None to C was removed in 2.0 (#261): "
308 "pass a Constants instance, or use the new Parser/Lexicon/"
309 "Policy API for per-call configuration"
310 )
311 if not isinstance(constants, Constants):
312 raise TypeError(
313 f"constants must be a Constants instance, got {constants!r}"
314 )
315 self._C = constants
316 self._snapshot_gen = -1 # invalidate: next _resolve() rebuilds
318 @property
319 def has_own_config(self) -> bool:
320 """True when this instance is not using the shared module-level
321 CONSTANTS."""
322 return self._C is not CONSTANTS
324 # -- fields ---------------------------------------------------------
326 def _get_field(self, member: str) -> str:
327 return getattr(self._parsed, _V2_FIELD.get(member, member))
329 def _set_field(self, member: str, value: str | list[str] | None) -> None:
330 if value is None:
331 joined = ""
332 elif isinstance(value, list):
333 for element in value:
334 if not isinstance(element, str):
335 raise TypeError(
336 f"name parts must be strings, got {element!r}")
337 joined = " ".join(value)
338 elif isinstance(value, str):
339 joined = value
340 else:
341 raise TypeError(
342 f"{member} must be a str, list, or None, got {value!r}")
343 # v1 setters stay on replace(): revise()'s vocabulary tags would
344 # change v1 parity
345 self._parsed = self._parsed.replace(
346 **{_V2_FIELD.get(member, member): joined})
348 def _list_for(self, member: str) -> list[str]:
349 # A "joined" continuation token ("Ph." + "D.") belongs to its
350 # predecessor's part, matching v1's fix_phd (suffix_list had ONE
351 # "Ph. D." element). ParsedName._text_for heals only the suffix
352 # string view (the ", " join); the facade list view heals for
353 # every role -- a continuation is never its own list element.
354 role = Role(_V2_FIELD.get(member, member))
355 parts: list[str] = []
356 folded: list[str] = []
357 for tok in self._parsed.tokens_for(role):
358 if "joined" in tok.tags and parts:
359 parts[-1] += " " + tok.text
360 elif FOLDED_TAG in tok.tags:
361 # middle_as_family fold: v1 PREPENDED middle_list to
362 # last_list -- keep the list view consistent with the
363 # string view (_text_for orders folded-first too)
364 folded.append(tok.text)
365 else:
366 parts.append(tok.text)
367 return folded + parts
369 @property
370 def title(self) -> str:
371 return self._get_field("title")
373 @title.setter
374 def title(self, value: str | list[str] | None) -> None:
375 self._set_field("title", value)
377 @property
378 def title_list(self) -> list[str]:
379 return self._list_for("title")
381 @property
382 def first(self) -> str:
383 return self._get_field("first")
385 @first.setter
386 def first(self, value: str | list[str] | None) -> None:
387 self._set_field("first", value)
389 @property
390 def first_list(self) -> list[str]:
391 return self._list_for("first")
393 @property
394 def middle(self) -> str:
395 return self._get_field("middle")
397 @middle.setter
398 def middle(self, value: str | list[str] | None) -> None:
399 self._set_field("middle", value)
401 @property
402 def middle_list(self) -> list[str]:
403 return self._list_for("middle")
405 @property
406 def last(self) -> str:
407 return self._get_field("last")
409 @last.setter
410 def last(self, value: str | list[str] | None) -> None:
411 self._set_field("last", value)
413 @property
414 def last_list(self) -> list[str]:
415 return self._list_for("last")
417 @property
418 def suffix(self) -> str:
419 return self._get_field("suffix")
421 @suffix.setter
422 def suffix(self, value: str | list[str] | None) -> None:
423 self._set_field("suffix", value)
425 @property
426 def suffix_list(self) -> list[str]:
427 return self._list_for("suffix")
429 @property
430 def nickname(self) -> str:
431 return self._get_field("nickname")
433 @nickname.setter
434 def nickname(self, value: str | list[str] | None) -> None:
435 self._set_field("nickname", value)
437 @property
438 def nickname_list(self) -> list[str]:
439 return self._list_for("nickname")
441 @property
442 def maiden(self) -> str:
443 return self._get_field("maiden")
445 @maiden.setter
446 def maiden(self, value: str | list[str] | None) -> None:
447 self._set_field("maiden", value)
449 @property
450 def maiden_list(self) -> list[str]:
451 return self._list_for("maiden")
453 # -- derived views ----------------------------------------------------
455 @property
456 def surnames_list(self) -> list[str]:
457 return self.middle_list + self.last_list
459 @property
460 def surnames(self) -> str:
461 return " ".join(self.surnames_list)
463 @property
464 def given_names_list(self) -> list[str]:
465 return self.first_list + self.middle_list
467 @property
468 def given_names(self) -> str:
469 return " ".join(self.given_names_list)
471 def _is_particle(self, text: str) -> bool:
472 self._resolve()
473 return _normalize(text) in self._lexicon.particles
475 def _is_conjunction(self, text: str) -> bool:
476 self._resolve()
477 return _normalize(text) in self._lexicon.conjunctions
479 def _split_last(self) -> tuple[list[str], list[str]]:
480 # rules.md#R2: "a name part whose every word is particle
481 # vocabulary is a part where none of them is doing a
482 # particle's work" -- the all-particle guard
483 # below is this rule, and predates its statement: v1 assumed a
484 # family name does not consist entirely of particles, e.g. the
485 # surname "Do" which also appears in PARTICLES. v1
486 # parser.py _split_last otherwise verbatim, vocabulary lookup
487 # at ACCESS time so assigned last names split too.
488 words = " ".join(self.last_list).split()
489 i = 0
490 while i < len(words) and self._is_particle(words[i]):
491 i += 1
492 if i == len(words):
493 return [], words
494 return words[:i], words[i:]
496 @property
497 def last_prefixes_list(self) -> list[str]:
498 return self._split_last()[0]
500 @property
501 def last_prefixes(self) -> str:
502 return " ".join(self._split_last()[0])
504 @property
505 def last_base_list(self) -> list[str]:
506 return self._split_last()[1]
508 @property
509 def last_base(self) -> str:
510 return " ".join(self._split_last()[1])
512 # -- initials -------------------------------------------------------------
514 def _process_initial(self, name_part: str, firstname: bool = False) -> str:
515 # after v1 parser.py:427, not verbatim: particles and
516 # conjunctions are filtered from initials unless the part is a
517 # first name. split() rather than split(" ") because split(" ")
518 # yields '' between repeated spaces and `part[0]` below would
519 # raise IndexError on it (#232). v1 stated the reason as
520 # `*_list` attributes bypassing whitespace normalization, which
521 # no longer holds -- the `*_list` properties are read-only in
522 # 2.x, and assignment through `hn.middle = ...` normalizes --
523 # but a doubled space anywhere in a part still reaches here.
524 parts = name_part.split()
525 initials = []
526 for part in parts:
527 # v1 parser.py:771 (1.4.0): is_conjunction was "in the
528 # conjunctions set AND NOT is_an_initial", so a dotted or
529 # bare-capital E/Y is the initial it looks like rather
530 # than the connective. The 2.0 facade dropped that half
531 # and lost the middle initial of 'Scott E. Werner' (#462).
532 # _render._INITIAL is v1's `initial` shape, kept in step
533 # with the pipeline's copy by tests/v2/test_regex_sync.py;
534 # the facade may import _render but not _pipeline
535 # (tests/v2/test_layering.py). Scoped here rather than in
536 # _is_conjunction: this is the only caller, and a future
537 # one should not inherit a decision made for initials.
538 conjunction = (self._is_conjunction(part)
539 and not _render._INITIAL.fullmatch(part))
540 if not (self._is_particle(part) or conjunction) or firstname:
541 initials.append(part[0])
542 if len(initials) > 0:
543 return self.initials_separator.join(initials)
544 # Return '' (never empty_attribute_default, which may be None)
545 # when a part has no initialable words. group_initials below
546 # decides what that means: one such element among others is
547 # dropped; a group that yields nothing AND is wholly particles
548 # initials its words; and a group that yields nothing for any
549 # other reason -- a conjunction, or particles mixed with one --
550 # is still dropped ("Vega, Santa de y" drops its middle).
551 return ""
553 def _initials_lists(self) -> tuple[list[str], list[str], list[str]]:
554 """Initials for the first, middle and last name groups. Parts
555 that yield no initials are dropped rather than kept as empty
556 strings -- except a part that is wholly PARTICLES, whose words
557 initial as ordinary name words since #404, so the prefix-only
558 middle name "de la" is no longer an example of the dropping.
559 """
560 def group_initials(names: list[str],
561 firstname: bool = False) -> list[str]:
562 got = [i for i in (self._process_initial(n, firstname)
563 for n in names if n) if i]
564 words = [w for n in names if n for w in n.split()]
565 if got or not words or not all(self._is_particle(w)
566 for w in words):
567 return got
568 # rules.md#R3: "except the particles of a part whose every
569 # word is one, which are not acting as particles there"
570 # -- nothing survived
571 # the filter, so the whole group is particles. The
572 # facade's twin of the core's
573 # UNJOINED_TAG. NOT pinned against it by the case runners,
574 # which compare the seven role fields only (Case carries
575 # no initials column); the covering test is
576 # tests/test_initials.py::test_initials_middle_name_all_prefixes,
577 # and since #484 the differential compares initials() on
578 # both surfaces for names whose roles agree. _split_last
579 # already applies the same guard to the base, which is why
580 # last_base was never empty here.
581 return [w[0] for w in words]
582 return (group_initials(self.first_list, True),
583 group_initials(self.middle_list),
584 group_initials(self.last_list))
586 def initials_list(self) -> list[str]:
587 first, middle, last = self._initials_lists()
588 return first + middle + last
590 def initials(self) -> str:
591 first, middle, last = self._initials_lists()
592 joiner = self.initials_delimiter + self.initials_separator
594 def group(items: list[str]) -> str:
595 return joiner.join(items) + self.initials_delimiter \
596 if items else ""
598 # A fully-empty result renders as "" -- the v1 fallback to
599 # C.empty_attribute_default (which may be None) is dropped per
600 # #255.
601 _s = self.initials_format.format(
602 first=group(first), middle=group(middle), last=group(last))
603 return self.collapse_whitespace(_s)
605 # -- comparison -----------------------------------------------------------
607 def matches(self, other: str | HumanName) -> bool:
608 """Component-wise case-insensitive comparison (v1 parity); a
609 str argument is parsed with this instance's resolved parser."""
610 if not isinstance(other, (str, HumanName)):
611 # pre-check so the error names the facade type a caller
612 # actually passed HumanName.matches(), not the core
613 # ParsedName it delegates to below
614 raise TypeError(
615 f"matches() takes a str or HumanName, got {other!r}")
616 target = other._parsed if isinstance(other, HumanName) else other
617 return self._parsed.matches(target, parser=self._resolve())
619 def comparison_key(self) -> tuple[str, ...]:
620 """One casefolded component per field in canonical order -- the
621 v1 replacement for ==/hash (#223); see ParsedName.comparison_key."""
622 return self._parsed.comparison_key()
624 # -- dunders ------------------------------------------------------------
626 def collapse_whitespace(self, string: str) -> str:
627 # v1 parser.py:976 verbatim, over _render's regexes (the #254
628 # collapse owns them; this public method keeps v1's narrower
629 # two-step contract for initials() and direct callers)
630 string = _render._SPACES.sub(" ", string.strip())
631 if string and _render._COMMA_CHAR.fullmatch(string[-1]):
632 string = string[:-1]
633 return string
635 def __str__(self) -> str:
636 if self.string_format is not None:
637 rendered = self.string_format.format(
638 **{k: v or "" for k, v in self.as_dict().items()})
639 # the full #254 collapse is _render._collapse -- one owner
640 # for the cleanup chain the v1 __str__ spelled inline
641 return _render._collapse(rendered)
642 return " ".join(self)
644 def __repr__(self) -> str:
645 attrs = (
646 f" title: {self.title or ''!r}\n"
647 f" first: {self.first or ''!r}\n"
648 f" middle: {self.middle or ''!r}\n"
649 f" last: {self.last or ''!r}\n"
650 f" suffix: {self.suffix or ''!r}\n"
651 f" nickname: {self.nickname or ''!r}\n"
652 f" maiden: {self.maiden or ''!r}"
653 )
654 return f"<{self.__class__.__name__} : [\n{attrs}\n]>"
656 def __iter__(self) -> Iterator[str]:
657 return (value for member in _MEMBERS
658 if (value := getattr(self, member)))
660 def __len__(self) -> int:
661 return sum(1 for member in _MEMBERS if getattr(self, member))
663 def __getitem__(self, key: str) -> str:
664 if isinstance(key, slice):
665 raise TypeError(
666 "slicing a HumanName was removed in 2.0 (#258); access "
667 "the named attributes instead"
668 )
669 # Role is a StrEnum, so Role members (and the plain 'given'/
670 # 'family' strings) reach here too -- translate to the v1
671 # spelling the facade actually exposes as attributes.
672 return getattr(self, _V1_SPELLING.get(key, key))
674 def __setattr__(self, name: str, value: object) -> None:
675 # "given"/"family" are the 2.0 spellings of first/last; the
676 # facade has no such attributes, so plain assignment creates a
677 # stray instance attribute while the parse (and .first/.last)
678 # keeps the old value -- a silently forked name. Warn but
679 # still set: ad-hoc attribute stashing is a legal v1 pattern,
680 # so any code that worked keeps working. Only these two names
681 # warn -- the other five 2.0 field names are real properties
682 # whose setters work, and Role members reach here as their
683 # string values (StrEnum).
684 if name in _V1_SPELLING:
685 warnings.warn(
686 f"assigning HumanName.{name} creates an inert attribute; "
687 f"the parse is unchanged -- use .{_V1_SPELLING[name]} "
688 f"(the v1 spelling) to update the name",
689 UserWarning, stacklevel=2)
690 super().__setattr__(name, value)
692 def as_dict(self, include_empty: bool = True) -> dict[str, str]:
693 """The seven v1-named components as a dict; include_empty=False
694 drops empty fields."""
695 d = {member: getattr(self, member) for member in _MEMBERS}
696 if include_empty:
697 return d
698 return {k: v for k, v in d.items() if v}
700 # -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) -----------
702 def __getstate__(self) -> dict[str, Any]:
703 # The emitted key set matches v1.4's pickle shape (minus
704 # encoding/_had_comma/_derived_*, which are v1-internal and
705 # ignored on read), so one __setstate__ path serves both eras.
706 state: dict[str, Any] = {
707 "_full_name": self._full_name,
708 "original": self.original,
709 "C": None if self._C is CONSTANTS else self._C,
710 "string_format": self.string_format,
711 "initials_format": self.initials_format,
712 "initials_delimiter": self.initials_delimiter,
713 "initials_separator": self.initials_separator,
714 "suffix_delimiter": self.suffix_delimiter,
715 }
716 for member in _MEMBERS:
717 state[f"{member}_list"] = getattr(self, f"{member}_list")
718 return state
720 def __setstate__(self, state: dict[str, Any]) -> None:
721 c = state.get("C")
722 self._C = CONSTANTS if c is None else c
723 self._snapshot_gen = -1
724 defaults = self._C._snapshot()[2]
725 self._string_format = state.get("string_format",
726 defaults.string_format)
727 self._initials_format = state.get("initials_format",
728 defaults.initials_format)
729 self._initials_delimiter = state.get("initials_delimiter",
730 defaults.initials_delimiter)
731 self._initials_separator = state.get("initials_separator",
732 defaults.initials_separator)
733 self._suffix_delimiter = state.get("suffix_delimiter",
734 defaults.suffix_delimiter)
735 self._full_name = state.get("_full_name", "")
736 # Components come back exactly as pickled
737 # (mechanisms.md#FACADE-CONTRACT): synthetic
738 # tokens, never a re-parse. Build them per *_list ENTRY rather
739 # than from one joined string -- an entry may hold several words
740 # ("Ph. D.", "Q.C. M.P."), and re-splitting the joined string on
741 # whitespace would promote each word to its own entry, which the
742 # suffix view then renders comma-separated ("Ph., D."). Marking
743 # continuation words "joined" is the inverse of _list_for's heal,
744 # so list -> pickle -> list is the identity v1 gave us.
745 tokens: list[Token] = []
746 for member in _MEMBERS:
747 role = Role(_V2_FIELD.get(member, member))
748 entries = state.get(f"{member}_list") or []
749 # Everything here is iterable but shreds differently: a str
750 # yields characters ("John" -> first "J o h n"), a Mapping
751 # yields keys only, bytes yields ints. v1 stored lists, so
752 # this only guards foreign or hand-built state -- but it
753 # names the field at the load site instead of failing
754 # opaquely later, or not at all.
755 if isinstance(entries, (str, bytes, Mapping)):
756 raise TypeError(
757 f"{member}_list must be a list of strings, not "
758 f"{type(entries).__name__} ({entries!r}); this "
759 f"pickle was not written by nameparser"
760 )
761 for entry in entries:
762 if not isinstance(entry, str):
763 raise TypeError(
764 f"{member}_list entries must be strings, got "
765 f"{entry!r}; this pickle was not written by "
766 f"nameparser"
767 )
768 for position, word in enumerate(entry.split()):
769 # UNCLASSIFIED_TAG for the same reason replace()
770 # stamps it: a pickle carries the *_list STRINGS
771 # and no tags, so nothing here was read by a parse
772 # and case repair must ask the vocabulary rather
773 # than read an absent conjunction tag. Without it a
774 # restored "juan ortega y gasset" repairs to
775 # "Ortega Y Gasset", which is neither v1's answer
776 # nor the same name's unpickled one.
777 tags = {UNCLASSIFIED_TAG}
778 if position:
779 tags.add("joined")
780 tokens.append(Token(word, None, role, frozenset(tags)))
781 self._parsed = ParsedName(
782 original=str(state.get("original", "")), tokens=tuple(tokens))