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

366 statements  

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. 

5 

6Layering: facade layer -- may import anything public plus _render. 

7""" 

8from __future__ import annotations 

9 

10import dataclasses 

11import warnings 

12from collections.abc import Iterator, Mapping 

13from typing import Any 

14 

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 

30 

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) 

37 

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) 

43 

44 

45 

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() 

61 

62 

63def _empty_parsed() -> ParsedName: 

64 return ParsedName(original="", tokens=(), ambiguities=()) 

65 

66 

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 """ 

75 

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) 

139 

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) 

156 

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(). 

162 

163 @property 

164 def string_format(self) -> str | None: 

165 return self._string_format 

166 

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 

173 

174 @property 

175 def initials_format(self) -> str: 

176 return self._initials_format 

177 

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 

184 

185 @property 

186 def initials_delimiter(self) -> str: 

187 return self._initials_delimiter 

188 

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 

195 

196 @property 

197 def initials_separator(self) -> str: 

198 return self._initials_separator 

199 

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 

206 

207 @property 

208 def suffix_delimiter(self) -> str | None: 

209 return self._suffix_delimiter 

210 

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 

221 

222 # -- config / parsing --------------------------------------------------- 

223 

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 

242 

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) 

252 

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 

268 

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) 

279 

280 @property 

281 def full_name(self) -> str: 

282 return self._full_name 

283 

284 @full_name.setter 

285 def full_name(self, value: str) -> None: 

286 self._apply_full_name(value) 

287 

288 @property 

289 def original(self) -> str: 

290 return self._parsed.original or self._full_name 

291 

292 @property 

293 def C(self) -> Constants: 

294 return self._C 

295 

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 

317 

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 

323 

324 # -- fields --------------------------------------------------------- 

325 

326 def _get_field(self, member: str) -> str: 

327 return getattr(self._parsed, _V2_FIELD.get(member, member)) 

328 

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}) 

347 

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 

368 

369 @property 

370 def title(self) -> str: 

371 return self._get_field("title") 

372 

373 @title.setter 

374 def title(self, value: str | list[str] | None) -> None: 

375 self._set_field("title", value) 

376 

377 @property 

378 def title_list(self) -> list[str]: 

379 return self._list_for("title") 

380 

381 @property 

382 def first(self) -> str: 

383 return self._get_field("first") 

384 

385 @first.setter 

386 def first(self, value: str | list[str] | None) -> None: 

387 self._set_field("first", value) 

388 

389 @property 

390 def first_list(self) -> list[str]: 

391 return self._list_for("first") 

392 

393 @property 

394 def middle(self) -> str: 

395 return self._get_field("middle") 

396 

397 @middle.setter 

398 def middle(self, value: str | list[str] | None) -> None: 

399 self._set_field("middle", value) 

400 

401 @property 

402 def middle_list(self) -> list[str]: 

403 return self._list_for("middle") 

404 

405 @property 

406 def last(self) -> str: 

407 return self._get_field("last") 

408 

409 @last.setter 

410 def last(self, value: str | list[str] | None) -> None: 

411 self._set_field("last", value) 

412 

413 @property 

414 def last_list(self) -> list[str]: 

415 return self._list_for("last") 

416 

417 @property 

418 def suffix(self) -> str: 

419 return self._get_field("suffix") 

420 

421 @suffix.setter 

422 def suffix(self, value: str | list[str] | None) -> None: 

423 self._set_field("suffix", value) 

424 

425 @property 

426 def suffix_list(self) -> list[str]: 

427 return self._list_for("suffix") 

428 

429 @property 

430 def nickname(self) -> str: 

431 return self._get_field("nickname") 

432 

433 @nickname.setter 

434 def nickname(self, value: str | list[str] | None) -> None: 

435 self._set_field("nickname", value) 

436 

437 @property 

438 def nickname_list(self) -> list[str]: 

439 return self._list_for("nickname") 

440 

441 @property 

442 def maiden(self) -> str: 

443 return self._get_field("maiden") 

444 

445 @maiden.setter 

446 def maiden(self, value: str | list[str] | None) -> None: 

447 self._set_field("maiden", value) 

448 

449 @property 

450 def maiden_list(self) -> list[str]: 

451 return self._list_for("maiden") 

452 

453 # -- derived views ---------------------------------------------------- 

454 

455 @property 

456 def surnames_list(self) -> list[str]: 

457 return self.middle_list + self.last_list 

458 

459 @property 

460 def surnames(self) -> str: 

461 return " ".join(self.surnames_list) 

462 

463 @property 

464 def given_names_list(self) -> list[str]: 

465 return self.first_list + self.middle_list 

466 

467 @property 

468 def given_names(self) -> str: 

469 return " ".join(self.given_names_list) 

470 

471 def _is_particle(self, text: str) -> bool: 

472 self._resolve() 

473 return _normalize(text) in self._lexicon.particles 

474 

475 def _is_conjunction(self, text: str) -> bool: 

476 self._resolve() 

477 return _normalize(text) in self._lexicon.conjunctions 

478 

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:] 

495 

496 @property 

497 def last_prefixes_list(self) -> list[str]: 

498 return self._split_last()[0] 

499 

500 @property 

501 def last_prefixes(self) -> str: 

502 return " ".join(self._split_last()[0]) 

503 

504 @property 

505 def last_base_list(self) -> list[str]: 

506 return self._split_last()[1] 

507 

508 @property 

509 def last_base(self) -> str: 

510 return " ".join(self._split_last()[1]) 

511 

512 # -- initials ------------------------------------------------------------- 

513 

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 "" 

552 

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)) 

585 

586 def initials_list(self) -> list[str]: 

587 first, middle, last = self._initials_lists() 

588 return first + middle + last 

589 

590 def initials(self) -> str: 

591 first, middle, last = self._initials_lists() 

592 joiner = self.initials_delimiter + self.initials_separator 

593 

594 def group(items: list[str]) -> str: 

595 return joiner.join(items) + self.initials_delimiter \ 

596 if items else "" 

597 

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) 

604 

605 # -- comparison ----------------------------------------------------------- 

606 

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()) 

618 

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() 

623 

624 # -- dunders ------------------------------------------------------------ 

625 

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 

634 

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) 

643 

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]>" 

655 

656 def __iter__(self) -> Iterator[str]: 

657 return (value for member in _MEMBERS 

658 if (value := getattr(self, member))) 

659 

660 def __len__(self) -> int: 

661 return sum(1 for member in _MEMBERS if getattr(self, member)) 

662 

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)) 

673 

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) 

691 

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} 

699 

700 # -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) ----------- 

701 

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 

719 

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))