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
1 change: 1 addition & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Release Log
- Fix a trailing suffix being silently dropped after an empty comma segment, e.g. ``"Doe, John,, Jr."`` losing the ``"Jr."``
- Remove ``__ne__``; Python 3 derives ``!=`` from ``__eq__`` automatically
- Change internal initials helper ``__process_initial__`` to ``_process_initial``: double-underscore-both-sides names are reserved for Python special methods; subclasses overriding the old name must rename their override
- Fix degenerate comma input (a bare ``","`` or an empty comma segment, e.g. ``"Doe,, Jr."``, ``"John Doe, Jr.,,"``) leaving an empty-string member in ``first_list``, ``last_list``, or ``suffix_list``; whitespace-only tokens assigned via the setters are dropped the same way
- Add ``non_first_name_prefixes`` to ``Constants``: a leading particle that is never a first name (e.g. ``"de Mesnil"``, ``"dos Santos"``) now parses as a surname with an empty first name, instead of treating the particle as the first name (closes #121)
- Add a first-class ``maiden`` field and ``maiden_delimiters`` to ``Constants``, so a delimiter (e.g. parenthesis) can be routed to ``maiden`` instead of ``nickname`` for alternate/maiden surnames, e.g. ``"Baker (Johnson), Jenny"`` (closes #22)
- Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes #111)
Expand Down
21 changes: 15 additions & 6 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,10 @@ def parse_full_name(self) -> None:
# parts[0], parts[1:...]

for part in parts[1:]:
self.suffix_list += self.expand_suffix_delimiter(part)
# skip empty segments from doubled commas, mirroring the
# parts[2:] guard in the lastname-comma path below
if part:
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 All @@ -1100,9 +1103,10 @@ def parse_full_name(self) -> None:
self.first_list.append(piece)
continue
if self.are_suffixes(pieces[i+1:]):
# the final piece always lands here: are_suffixes() is
# vacuously True for the empty tail, making this the
# last-name branch as well as the suffix branch
# any piece reaching this check as the final piece
# lands here: are_suffixes() is vacuously True for the
# empty tail, making this the last-name branch as well
# as the suffix branch
self.last_list.append(piece)
self.suffix_list = pieces[i+1:] + self.suffix_list
break
Expand Down Expand Up @@ -1164,7 +1168,9 @@ def parse_full_name(self) -> None:
def parse_pieces(self, parts: Iterable[str], additional_parts_count: int = 0) -> list[str]:
"""
Split parts on spaces and remove commas, join on conjunctions and
lastname prefixes. If parts have periods in the middle, try splitting
lastname prefixes. Tokens that are empty after stripping spaces and
commas are dropped, so the returned pieces never contain empty
strings. If parts have periods in the middle, try splitting
on periods and check if the parts are titles or suffixes. If they are
add to the constant so they will be found.

Expand All @@ -1183,7 +1189,10 @@ def parse_pieces(self, parts: Iterable[str], additional_parts_count: int = 0) ->
if not isinstance(part, (str, bytes)):
raise TypeError("Name parts must be strings. "
f" Got {type(part)}")
output += [x.strip(' ,') for x in part.split(' ')]
# drop tokens that strip to nothing (e.g. from a bare "," input or
# an empty comma segment) so no empty piece reaches the parse
# loops and the public *_list attributes
output += [s for s in (x.strip(' ,') for x in part.split(' ')) if s]

# If part contains periods, check if it's multiple titles or suffixes
# together without spaces if so, add the new part with periods to the
Expand Down
35 changes: 35 additions & 0 deletions tests/test_python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,41 @@ def test_prefix_names(self) -> None:
self.m(hn.first, "vai", hn)
self.m(hn.last, "la", hn)

def test_degenerate_comma_input_leaves_no_empty_pieces(self) -> None:
# Regression: HumanName(',') (no-comma path after whitespace collapse)
# and HumanName('Doe,, Jr.') (lastname-comma path) appended '' to
# first_list — a silent empty member in the public *_list attributes.
hn = HumanName(",")
self.assertEqual(hn.first_list, [])
self.assertEqual(hn.middle_list, [])
self.assertEqual(hn.last_list, [])
self.assertEqual(len(hn), 0)
hn = HumanName("Doe,, Jr.")
self.assertEqual(hn.first_list, [])
self.m(hn.last, "Doe", hn)
self.m(hn.suffix, "Jr.", hn)
# empty parts[0] exercises the lastname_pieces call site: the empty
# last-name segment must not become a member of last_list
hn = HumanName(", John")
self.assertEqual(hn.last_list, [])
self.m(hn.first, "John", hn)

def test_assignment_filters_empty_tokens(self) -> None:
# parse_pieces() drops tokens that strip to nothing at every entry
# point, including the setters: whitespace-only strings and empty
# list members never become *_list members (they previously survived,
# e.g. hn.first = ' ' left first == ' ' and middle 'a b' gained an
# empty member from ['a', '', 'b']).
hn = HumanName("John Doe")
hn.first = " "
self.assertEqual(hn.first_list, [])
self.m(hn.first, "", hn)
hn.middle = ["a", "", "b"]
self.assertEqual(hn.middle_list, ["a", "b"])
self.m(hn.middle, "a b", hn)
hn["last"] = ["", "Smith"]
self.assertEqual(hn.last_list, ["Smith"])

def test_blank_name(self) -> None:
hn = HumanName()
self.m(hn.first, "", hn)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_suffixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,13 @@ def test_empty_comma_segment_does_not_drop_following_suffix(self) -> None:
# regression that the single-empty case above would not catch)
hn = HumanName("Doe, John,, Jr.,, III")
self.m(hn.suffix, "Jr., III", hn)

def test_suffix_comma_empty_segment_not_added_to_suffix_list(self) -> None:
# Regression: in suffix-comma format ("John Doe, Jr.,," -- one
# trailing comma survives collapse_whitespace), the unguarded
# parts[1:] loop leaked '' into suffix_list via
# expand_suffix_delimiter('') returning [''].
hn = HumanName("John Doe, Jr.,,")
self.assertEqual(hn.suffix_list, ["Jr."])
self.m(hn.first, "John", hn)
self.m(hn.last, "Doe", hn)
Loading