Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down
31 changes: 22 additions & 9 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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

Expand Down
46 changes: 41 additions & 5 deletions tests/test_suffixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down