From 021823e84dcb6b455053091b66ad41367d20c520 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 02:36:03 -0700 Subject: [PATCH 1/2] Apply suffix_delimiter only at suffix-consumption sites Previously the delimiter split ran unconditionally on every post-comma segment before the parser had decided which segment is actually a suffix group. That let it leak into first/middle-name segments in inverted format (e.g. "Doe, Mary - Kate, RN"), which was documented as a known limitation. Move the split to the three places that actually treat a segment as suffixes: the suffix-comma vs. lastname-comma format detection, and the two suffix_list += parts[...] consumption points. The detection check now expands parts[1] to correctly recognize delimiter-joined suffixes (e.g. "RN - CRNA") without ever expanding a segment that turns out to be a name. --- nameparser/config/__init__.py | 11 +++++------ nameparser/parser.py | 27 ++++++++++++++++++--------- tests/test_suffixes.py | 12 +++++++----- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 6c2a7ab..7ce7173 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -343,12 +343,11 @@ class Constants: the full name is already split on bare commas first, and each resulting part is stripped of surrounding whitespace before this step runs. - Known limitation: the expansion is applied to all post-comma parts, not - just suffix groups. In inverted format (``"Last, First, suffix"``), the - first-name part is also split on the delimiter. In practice this is - harmless since first names rarely contain the delimiter string, but a - name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "`` - would misparse. + The delimiter is only applied to parts once they've been identified as + a suffix group, so it never leaks into a first- or middle-name part. For + example, in inverted format (``"Last, First, suffix"``) a hyphenated + given name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "`` + does not get mistaken for a suffix split. """ empty_attribute_default = '' diff --git a/nameparser/parser.py b/nameparser/parser.py index a2bc9c0..1daf9d2 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -674,6 +674,16 @@ def is_suffix_lenient(self, piece: str) -> bool: """ return lc(piece) in self.C.suffix_not_acronyms or self.is_suffix(piece) + def expand_suffix_delimiter(self, part: str) -> list[str]: + """Split a single post-comma part on :py:attr:`suffix_delimiter`, + if configured. Used only at suffix-consumption sites, where a part + has already been identified as a suffix group, so splitting it + further can't misparse an unrelated name segment. + """ + if not self.suffix_delimiter: + return [part] + return [p for p in (p.strip() for p in part.split(self.suffix_delimiter)) if p] + def are_suffixes_after_comma(self, pieces: Iterable[str]) -> bool: """Return True if all pieces are suffixes by the lenient :py:func:`is_suffix_lenient` test. Used when detecting suffix-comma @@ -1004,12 +1014,6 @@ def parse_full_name(self) -> None: parts = [x.strip() for x in self._full_name.split(",")] self._had_comma = len(parts) > 1 - if self.suffix_delimiter and len(parts) > 1: - expanded = [parts[0]] - for part in parts[1:]: - expanded.extend([p for p in (p.strip() for p in part.split(self.suffix_delimiter)) if p]) - parts = expanded - log.debug("full_name: %s", self._full_name) log.debug("parts: %s", parts) @@ -1063,14 +1067,18 @@ def parse_full_name(self) -> None: post_comma_pieces = self.parse_pieces(parts[1].split(' '), 1) - if self.are_suffixes_after_comma(parts[1].split(' ')) \ + suffix_delimiter_pieces = [word for part in self.expand_suffix_delimiter(parts[1]) + for word in part.split(' ')] + + if self.are_suffixes_after_comma(suffix_delimiter_pieces) \ and len(parts[0].split(' ')) > 1: # suffix comma: # title first middle last [suffix], suffix [suffix] [, suffix] # parts[0], parts[1:...] - self.suffix_list += parts[1:] + for part in parts[1:]: + self.suffix_list += self.expand_suffix_delimiter(part) pieces = self.parse_pieces(parts[0].split(' ')) pieces = self._join_bound_first_name(pieces, reserve_last=True) log.debug("pieces: %s", str(pieces)) @@ -1144,7 +1152,8 @@ def parse_full_name(self) -> None: self.middle_list.append(piece) try: if parts[2]: - self.suffix_list += parts[2:] + for part in parts[2:]: + self.suffix_list += self.expand_suffix_delimiter(part) except IndexError: pass diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 8e7248e..b71d49c 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -265,12 +265,14 @@ def test_suffix_delimiter_comma_space_is_noop(self) -> None: hn = HumanName("John Doe, MD, PhD", suffix_delimiter=", ") self.m(hn.suffix, "MD, PhD", hn) - def test_suffix_delimiter_inverted_format_known_limitation(self) -> None: - # In inverted format, the first-name part is also split on the delimiter. - # "Mary - Kate" becomes two separate parts, causing a wrong parse. - # This is a documented limitation — do not "fix" it without a broader solution. + def test_suffix_delimiter_inverted_format_not_misparsed(self) -> None: + # The delimiter only expands parts once they're identified as a + # suffix group, so a hyphenated given name in inverted format isn't + # mistaken for a suffix split. hn = HumanName("Doe, Mary - Kate, RN", suffix_delimiter=" - ") - self.assertNotEqual(hn.first, "Mary - Kate") + self.m(hn.first, "Mary", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "RN", hn) def test_suffix_acronyms_ambiguous_is_customizable(self) -> None: from nameparser.config import Constants From 211f8c33ffce7e38abb7df53fd66f921e9cfa134 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 02:42:24 -0700 Subject: [PATCH 2/2] Address PR review: pin down suffix_delimiter edge cases, clarify comments - Lock in hn.middle for the "Doe, Mary - Kate, RN" case so the stray hyphen artifact (a pre-existing tokenization quirk, reproducible without suffix_delimiter set) can't silently drift. - Add coverage for the parts[1:] loop expanding more than one comma segment, for a multi-word token on one side of the delimiter in the detection check, and for delimiter no-op parity with the no-delimiter baseline when the format isn't detected as suffix-comma. - Note in expand_suffix_delimiter's docstring that it's a no-op without a configured delimiter, and comment why detection needs the delimiter-expanded view of parts[1]. --- nameparser/parser.py | 6 +++++- tests/test_suffixes.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/nameparser/parser.py b/nameparser/parser.py index 1daf9d2..8c92d20 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -678,7 +678,8 @@ def expand_suffix_delimiter(self, part: str) -> list[str]: """Split a single post-comma part on :py:attr:`suffix_delimiter`, if configured. Used only at suffix-consumption sites, where a part has already been identified as a suffix group, so splitting it - further can't misparse an unrelated name segment. + further can't misparse an unrelated name segment. Returns ``[part]`` + unchanged if no delimiter is configured. """ if not self.suffix_delimiter: return [part] @@ -1067,6 +1068,9 @@ def parse_full_name(self) -> None: post_comma_pieces = self.parse_pieces(parts[1].split(' '), 1) + # Detection must see the delimiter-expanded words too, or a + # delimiter-joined suffix group like "RN - CRNA" would never be + # recognized as suffix-comma format in the first place. suffix_delimiter_pieces = [word for part in self.expand_suffix_delimiter(parts[1]) for word in part.split(' ')] diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index b71d49c..9fb899c 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -273,6 +273,40 @@ def test_suffix_delimiter_inverted_format_not_misparsed(self) -> None: self.m(hn.first, "Mary", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "RN", hn) + # "Kate" stays in the given-name segment rather than being pulled + # into the suffix, since it's separated from "RN" by its own comma. + # The bare "-" landing in middle is a pre-existing, delimiter- + # independent quirk of tokenizing a lone hyphen (reproducible with + # suffix_delimiter unset), not something this fix is responsible for. + self.m(hn.middle, "- Kate", hn) + + def test_suffix_delimiter_expands_each_comma_segment(self) -> None: + # parts[1:] holds two separate comma segments here ("MD - PhD" and + # "FACS"); each must be expanded on its own, not just the first. + hn = HumanName("John Doe, MD - PhD, FACS", suffix_delimiter=" - ") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD, PhD, FACS", hn) + + def test_suffix_delimiter_detection_with_multi_word_side(self) -> None: + # The suffix-comma detection check flattens on spaces after + # expanding on the delimiter, so a multi-word token on one side of + # the delimiter is still tokenized correctly. + hn = HumanName("Doe, John, MD PhD - FACS Fellow", suffix_delimiter=" - ") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD PhD, FACS Fellow", hn) + + def test_suffix_delimiter_no_effect_when_not_suffix_comma(self) -> None: + # When the comma format isn't recognized as suffix-comma (here the + # last-name part is a single word), the delimiter must not affect + # parsing at all: output should match the no-delimiter baseline. + with_delim = HumanName("Smith, MD - PhD - FACS", suffix_delimiter=" - ") + without_delim = HumanName("Smith, MD - PhD - FACS") + self.assertEqual( + (with_delim.first, with_delim.middle, with_delim.last, with_delim.suffix), + (without_delim.first, without_delim.middle, without_delim.last, without_delim.suffix), + ) def test_suffix_acronyms_ambiguous_is_customizable(self) -> None: from nameparser.config import Constants