|
10 | 10 | import re |
11 | 11 | import warnings |
12 | 12 | from collections.abc import Iterator |
| 13 | +from typing import Any |
13 | 14 |
|
14 | 15 | from nameparser._config_shim import CONSTANTS, Constants, _cached_parser |
15 | 16 | from nameparser._lexicon import _normalize |
@@ -229,11 +230,16 @@ def _apply_full_name(self, value: str) -> None: |
229 | 230 | if self._C.capitalize_name: |
230 | 231 | self.capitalize() # v1 parser.py:1653 parity |
231 | 232 |
|
232 | | - def capitalize(self) -> None: |
233 | | - """Minimal M6 body (M10 ports the full force-semantics rules): |
234 | | - re-capitalize the current parse against the bound lexicon.""" |
| 233 | + def capitalize(self, force: bool | None = None) -> None: |
| 234 | + """Re-capitalize the current parse against the bound lexicon. |
| 235 | + force=None reads the bound Constants' render default |
| 236 | + (force_mixed_case_capitalization); the core's capitalized() |
| 237 | + implements the single-case gate (v1 parity) -- not |
| 238 | + re-implemented here.""" |
235 | 239 | self._resolve() |
236 | | - self._parsed = self._parsed.capitalized(self._lexicon, force=True) |
| 240 | + if force is None: |
| 241 | + force = self._C.force_mixed_case_capitalization |
| 242 | + self._parsed = self._parsed.capitalized(self._lexicon, force=force) |
237 | 243 |
|
238 | 244 | @property |
239 | 245 | def full_name(self) -> str: |
@@ -485,6 +491,17 @@ def group(items: list[str]) -> str: |
485 | 491 | first=group(first), middle=group(middle), last=group(last)) |
486 | 492 | return self.collapse_whitespace(_s) |
487 | 493 |
|
| 494 | + # -- comparison ----------------------------------------------------------- |
| 495 | + |
| 496 | + def matches(self, other: str | HumanName) -> bool: |
| 497 | + """Component-wise case-insensitive comparison (v1 parity); a |
| 498 | + str argument is parsed with this instance's resolved parser.""" |
| 499 | + target = other._parsed if isinstance(other, HumanName) else other |
| 500 | + return self._parsed.matches(target, parser=self._resolve()) |
| 501 | + |
| 502 | + def comparison_key(self) -> tuple[str, ...]: |
| 503 | + return self._parsed.comparison_key() |
| 504 | + |
488 | 505 | # -- dunders ------------------------------------------------------------ |
489 | 506 |
|
490 | 507 | def collapse_whitespace(self, string: str) -> str: |
@@ -535,3 +552,55 @@ def as_dict(self, include_empty: bool = True) -> dict[str, str]: |
535 | 552 | if include_empty: |
536 | 553 | return d |
537 | 554 | return {k: v for k, v in d.items() if v} |
| 555 | + |
| 556 | + # -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) ----------- |
| 557 | + |
| 558 | + def __getstate__(self) -> dict[str, Any]: |
| 559 | + # The emitted key set matches v1.4's pickle shape (minus |
| 560 | + # encoding/_had_comma/_derived_*, which are v1-internal and |
| 561 | + # ignored on read), so one __setstate__ path serves both eras. |
| 562 | + state: dict[str, Any] = { |
| 563 | + "_full_name": self._full_name, |
| 564 | + "original": self.original, |
| 565 | + "C": None if self._C is CONSTANTS else self._C, |
| 566 | + "string_format": self.string_format, |
| 567 | + "initials_format": self.initials_format, |
| 568 | + "initials_delimiter": self.initials_delimiter, |
| 569 | + "initials_separator": self.initials_separator, |
| 570 | + "suffix_delimiter": self.suffix_delimiter, |
| 571 | + } |
| 572 | + for member in _MEMBERS: |
| 573 | + state[f"{member}_list"] = getattr(self, f"{member}_list") |
| 574 | + return state |
| 575 | + |
| 576 | + def __setstate__(self, state: dict[str, Any]) -> None: |
| 577 | + c = state.get("C") |
| 578 | + self._C = CONSTANTS if c is None else c |
| 579 | + self._snapshot_gen = -1 |
| 580 | + defaults = self._C._snapshot()[2] |
| 581 | + self._string_format = state.get("string_format", |
| 582 | + defaults.string_format) |
| 583 | + self._initials_format = state.get("initials_format", |
| 584 | + defaults.initials_format) |
| 585 | + self._initials_delimiter = state.get("initials_delimiter", |
| 586 | + defaults.initials_delimiter) |
| 587 | + self._initials_separator = state.get("initials_separator", |
| 588 | + defaults.initials_separator) |
| 589 | + self._suffix_delimiter = state.get("suffix_delimiter", |
| 590 | + defaults.suffix_delimiter) |
| 591 | + self._full_name = state.get("_full_name", "") |
| 592 | + # components come back exactly as pickled (spec §2): synthetic |
| 593 | + # tokens via replace(), never a re-parse. Known edge: replace() |
| 594 | + # re-splits on whitespace without the "joined" tag, so joined-tag |
| 595 | + # healing is lost for multi-word list elements -- v1's fix_phd |
| 596 | + # suffix pickles as ["Ph. D."] but round-trips to ["Ph.", "D."], |
| 597 | + # rendering the suffix as "Ph., D.". Classify in the differential |
| 598 | + # harness / M12 if it surfaces. |
| 599 | + parsed = ParsedName(original=str(state.get("original", "")), |
| 600 | + tokens=(), ambiguities=()) |
| 601 | + fields = {} |
| 602 | + for member in _MEMBERS: |
| 603 | + values = state.get(f"{member}_list") or [] |
| 604 | + fields[_V2_FIELD.get(member, member)] = " ".join(values) |
| 605 | + self._parsed = parsed.replace( |
| 606 | + **{k: v for k, v in fields.items() if v}) |
0 commit comments