Skip to content

Commit 24aa787

Browse files
derek73claude
andcommitted
fix: pickled HumanName keeps multi-word suffix entries intact
__setstate__ restored components by joining each pickled *_list into one string and letting replace() re-split it on whitespace. That destroyed the entry boundaries: suffix_list ["Ph. D."] came back as ["Ph.", "D."], and the suffix view's ", " join then rendered one credential as two. Build tokens per entry instead, tagging continuation words "joined" -- the inverse of _list_for's heal -- so list -> pickle -> list is the identity again. This was a regression against 1.4.0, not an inherited quirk: all eight affected corpus names round-trip unchanged under a live 1.4.0, and now match it byte for byte. 8 of the 486 differential-corpus names drifted before the fix (any multi-word suffix run in one comma segment: "Jr. MD", "Q.C. M.P.", "V Jr.", "MD PhD - FACS Fellow"); 0 drift after, checking str(), as_dict() and all six _list attributes. The differential harness could not have caught this -- it compares fresh v1 and 2.0 parses and never crosses a pickle boundary. Note the list SETTER (_set_field) splits the same way on purpose: 1.4.0 also renders hn.suffix = ["Ph. D.", "MD"] as "Ph., D., MD", so that path is v1 parity and is deliberately left alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 08cfeb5 commit 24aa787

2 files changed

Lines changed: 51 additions & 15 deletions

File tree

nameparser/_facade.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from nameparser._config_shim import CONSTANTS, Constants, _cached_parser
3232
from nameparser._lexicon import _normalize
3333
from nameparser._parser import Parser
34-
from nameparser._types import FOLDED_TAG, ParsedName, Role
34+
from nameparser._types import FOLDED_TAG, ParsedName, Role, Token
3535

3636
_V2_FIELD = {"first": "given", "last": "family"} # v1 name -> v2 name
3737
_V1_SPELLING = {v2: v1 for v1, v2 in _V2_FIELD.items()}
@@ -662,18 +662,21 @@ def __setstate__(self, state: dict[str, Any]) -> None:
662662
self._suffix_delimiter = state.get("suffix_delimiter",
663663
defaults.suffix_delimiter)
664664
self._full_name = state.get("_full_name", "")
665-
# components come back exactly as pickled (spec §2): synthetic
666-
# tokens via replace(), never a re-parse. Known edge: replace()
667-
# re-splits on whitespace without the "joined" tag, so joined-tag
668-
# healing is lost for multi-word list elements -- v1's fix_phd
669-
# suffix pickles as ["Ph. D."] but round-trips to ["Ph.", "D."],
670-
# rendering the suffix as "Ph., D.". Classify in the differential
671-
# harness / M12 if it surfaces.
672-
parsed = ParsedName(original=str(state.get("original", "")),
673-
tokens=(), ambiguities=())
674-
fields = {}
665+
# Components come back exactly as pickled (spec §2): synthetic
666+
# tokens, never a re-parse. Build them per *_list ENTRY rather
667+
# than from one joined string -- an entry may hold several words
668+
# ("Ph. D.", "Q.C. M.P."), and re-splitting the joined string on
669+
# whitespace would promote each word to its own entry, which the
670+
# suffix view then renders comma-separated ("Ph., D."). Marking
671+
# continuation words "joined" is the inverse of _list_for's heal,
672+
# so list -> pickle -> list is the identity v1 gave us.
673+
tokens: list[Token] = []
675674
for member in _MEMBERS:
676-
values = state.get(f"{member}_list") or []
677-
fields[_V2_FIELD.get(member, member)] = " ".join(values)
678-
self._parsed = parsed.replace(
679-
**{k: v for k, v in fields.items() if v})
675+
role = Role(_V2_FIELD.get(member, member))
676+
for entry in state.get(f"{member}_list") or []:
677+
for position, word in enumerate(entry.split()):
678+
tokens.append(Token(
679+
word, None, role,
680+
frozenset({"joined"}) if position else frozenset()))
681+
self._parsed = ParsedName(
682+
original=str(state.get("original", "")), tokens=tuple(tokens))

tests/v2/test_facade.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,39 @@ def test_pickle_round_trip_preserves_components() -> None:
307307
assert loaded.C is CONSTANTS # shared sentinel restored
308308

309309

310+
#: Every name in the 486-name differential corpus whose HumanName pickle
311+
#: round-trip drifted before the __setstate__ element-boundary fix. Each
312+
#: carries a multi-word suffix entry -- a "joined" continuation token --
313+
#: inside one comma segment. Verified against a live 1.4.0: all eight
314+
#: round-trip unchanged there, so drift here is a regression, not an
315+
#: inherited quirk.
316+
_MULTIWORD_SUFFIX_NAMES = [
317+
"Andrew Perkins, Jr., Col. (Ret)",
318+
"Clarke, Kenneth, Q.C. M.P.",
319+
"Doe, John, MD PhD - FACS Fellow",
320+
"Franklin Washington, Jr. MD",
321+
"John Smith Ph. D.",
322+
"John Smith, Ph. D.",
323+
"John Smith, V Jr.",
324+
"John Smith, V MD",
325+
]
326+
327+
328+
@pytest.mark.parametrize("raw", _MULTIWORD_SUFFIX_NAMES)
329+
def test_pickle_round_trip_preserves_multiword_suffix_entries(raw: str) -> None:
330+
# __setstate__ restores components from the pickled *_list values.
331+
# Joining them into one string and letting replace() re-split on
332+
# whitespace destroys the entry boundaries, and the suffix view's
333+
# ", " join then renders one credential as two ("Ph." + "D." ->
334+
# "Ph., D."). Entries must survive as entries.
335+
n = HumanName(raw)
336+
loaded = pickle.loads(pickle.dumps(n))
337+
assert loaded.suffix_list == n.suffix_list
338+
assert loaded.suffix == n.suffix
339+
assert str(loaded) == str(n)
340+
assert loaded.as_dict() == n.as_dict()
341+
342+
310343
# -- Gap 2: parse_full_name() (v1's documented re-parse idiom) -------------
311344

312345

0 commit comments

Comments
 (0)