Skip to content

Commit b64a44f

Browse files
authored
Merge pull request #206 from derek73/fix/suffix-delimiter-consumption-site
Apply suffix_delimiter only at suffix-consumption sites
2 parents 76098cc + 211f8c3 commit b64a44f

3 files changed

Lines changed: 68 additions & 20 deletions

File tree

nameparser/config/__init__.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -343,12 +343,11 @@ class Constants:
343343
the full name is already split on bare commas first, and each resulting
344344
part is stripped of surrounding whitespace before this step runs.
345345
346-
Known limitation: the expansion is applied to all post-comma parts, not
347-
just suffix groups. In inverted format (``"Last, First, suffix"``), the
348-
first-name part is also split on the delimiter. In practice this is
349-
harmless since first names rarely contain the delimiter string, but a
350-
name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "``
351-
would misparse.
346+
The delimiter is only applied to parts once they've been identified as
347+
a suffix group, so it never leaks into a first- or middle-name part. For
348+
example, in inverted format (``"Last, First, suffix"``) a hyphenated
349+
given name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "``
350+
does not get mistaken for a suffix split.
352351
"""
353352

354353
empty_attribute_default = ''

nameparser/parser.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,17 @@ def is_suffix_lenient(self, piece: str) -> bool:
674674
"""
675675
return lc(piece) in self.C.suffix_not_acronyms or self.is_suffix(piece)
676676

677+
def expand_suffix_delimiter(self, part: str) -> list[str]:
678+
"""Split a single post-comma part on :py:attr:`suffix_delimiter`,
679+
if configured. Used only at suffix-consumption sites, where a part
680+
has already been identified as a suffix group, so splitting it
681+
further can't misparse an unrelated name segment. Returns ``[part]``
682+
unchanged if no delimiter is configured.
683+
"""
684+
if not self.suffix_delimiter:
685+
return [part]
686+
return [p for p in (p.strip() for p in part.split(self.suffix_delimiter)) if p]
687+
677688
def are_suffixes_after_comma(self, pieces: Iterable[str]) -> bool:
678689
"""Return True if all pieces are suffixes by the lenient
679690
:py:func:`is_suffix_lenient` test. Used when detecting suffix-comma
@@ -1004,12 +1015,6 @@ def parse_full_name(self) -> None:
10041015
parts = [x.strip() for x in self._full_name.split(",")]
10051016
self._had_comma = len(parts) > 1
10061017

1007-
if self.suffix_delimiter and len(parts) > 1:
1008-
expanded = [parts[0]]
1009-
for part in parts[1:]:
1010-
expanded.extend([p for p in (p.strip() for p in part.split(self.suffix_delimiter)) if p])
1011-
parts = expanded
1012-
10131018
log.debug("full_name: %s", self._full_name)
10141019
log.debug("parts: %s", parts)
10151020

@@ -1063,14 +1068,21 @@ def parse_full_name(self) -> None:
10631068

10641069
post_comma_pieces = self.parse_pieces(parts[1].split(' '), 1)
10651070

1066-
if self.are_suffixes_after_comma(parts[1].split(' ')) \
1071+
# Detection must see the delimiter-expanded words too, or a
1072+
# delimiter-joined suffix group like "RN - CRNA" would never be
1073+
# recognized as suffix-comma format in the first place.
1074+
suffix_delimiter_pieces = [word for part in self.expand_suffix_delimiter(parts[1])
1075+
for word in part.split(' ')]
1076+
1077+
if self.are_suffixes_after_comma(suffix_delimiter_pieces) \
10671078
and len(parts[0].split(' ')) > 1:
10681079

10691080
# suffix comma:
10701081
# title first middle last [suffix], suffix [suffix] [, suffix]
10711082
# parts[0], parts[1:...]
10721083

1073-
self.suffix_list += parts[1:]
1084+
for part in parts[1:]:
1085+
self.suffix_list += self.expand_suffix_delimiter(part)
10741086
pieces = self.parse_pieces(parts[0].split(' '))
10751087
pieces = self._join_bound_first_name(pieces, reserve_last=True)
10761088
log.debug("pieces: %s", str(pieces))
@@ -1144,7 +1156,8 @@ def parse_full_name(self) -> None:
11441156
self.middle_list.append(piece)
11451157
try:
11461158
if parts[2]:
1147-
self.suffix_list += parts[2:]
1159+
for part in parts[2:]:
1160+
self.suffix_list += self.expand_suffix_delimiter(part)
11481161
except IndexError:
11491162
pass
11501163

tests/test_suffixes.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,48 @@ def test_suffix_delimiter_comma_space_is_noop(self) -> None:
265265
hn = HumanName("John Doe, MD, PhD", suffix_delimiter=", ")
266266
self.m(hn.suffix, "MD, PhD", hn)
267267

268-
def test_suffix_delimiter_inverted_format_known_limitation(self) -> None:
269-
# In inverted format, the first-name part is also split on the delimiter.
270-
# "Mary - Kate" becomes two separate parts, causing a wrong parse.
271-
# This is a documented limitation — do not "fix" it without a broader solution.
268+
def test_suffix_delimiter_inverted_format_not_misparsed(self) -> None:
269+
# The delimiter only expands parts once they're identified as a
270+
# suffix group, so a hyphenated given name in inverted format isn't
271+
# mistaken for a suffix split.
272272
hn = HumanName("Doe, Mary - Kate, RN", suffix_delimiter=" - ")
273-
self.assertNotEqual(hn.first, "Mary - Kate")
273+
self.m(hn.first, "Mary", hn)
274+
self.m(hn.last, "Doe", hn)
275+
self.m(hn.suffix, "RN", hn)
276+
# "Kate" stays in the given-name segment rather than being pulled
277+
# into the suffix, since it's separated from "RN" by its own comma.
278+
# The bare "-" landing in middle is a pre-existing, delimiter-
279+
# independent quirk of tokenizing a lone hyphen (reproducible with
280+
# suffix_delimiter unset), not something this fix is responsible for.
281+
self.m(hn.middle, "- Kate", hn)
282+
283+
def test_suffix_delimiter_expands_each_comma_segment(self) -> None:
284+
# parts[1:] holds two separate comma segments here ("MD - PhD" and
285+
# "FACS"); each must be expanded on its own, not just the first.
286+
hn = HumanName("John Doe, MD - PhD, FACS", suffix_delimiter=" - ")
287+
self.m(hn.first, "John", hn)
288+
self.m(hn.last, "Doe", hn)
289+
self.m(hn.suffix, "MD, PhD, FACS", hn)
290+
291+
def test_suffix_delimiter_detection_with_multi_word_side(self) -> None:
292+
# The suffix-comma detection check flattens on spaces after
293+
# expanding on the delimiter, so a multi-word token on one side of
294+
# the delimiter is still tokenized correctly.
295+
hn = HumanName("Doe, John, MD PhD - FACS Fellow", suffix_delimiter=" - ")
296+
self.m(hn.first, "John", hn)
297+
self.m(hn.last, "Doe", hn)
298+
self.m(hn.suffix, "MD PhD, FACS Fellow", hn)
299+
300+
def test_suffix_delimiter_no_effect_when_not_suffix_comma(self) -> None:
301+
# When the comma format isn't recognized as suffix-comma (here the
302+
# last-name part is a single word), the delimiter must not affect
303+
# parsing at all: output should match the no-delimiter baseline.
304+
with_delim = HumanName("Smith, MD - PhD - FACS", suffix_delimiter=" - ")
305+
without_delim = HumanName("Smith, MD - PhD - FACS")
306+
self.assertEqual(
307+
(with_delim.first, with_delim.middle, with_delim.last, with_delim.suffix),
308+
(without_delim.first, without_delim.middle, without_delim.last, without_delim.suffix),
309+
)
274310

275311
def test_suffix_acronyms_ambiguous_is_customizable(self) -> None:
276312
from nameparser.config import Constants

0 commit comments

Comments
 (0)