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..8c92d20 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -674,6 +674,17 @@ 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. Returns ``[part]`` + unchanged if no delimiter is configured. + """ + 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 +1015,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 +1068,21 @@ 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(' ')) \ + # 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(' ')] + + 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 +1156,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..9fb899c 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -265,12 +265,48 @@ 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) + # "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