diff --git a/docs/release_log.rst b/docs/release_log.rst index 4b2c540..373e43e 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -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) diff --git a/nameparser/parser.py b/nameparser/parser.py index 38a690a..0ee4f14 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -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)) @@ -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 @@ -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. @@ -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 diff --git a/tests/test_python_api.py b/tests/test_python_api.py index 7ba671e..aaf4661 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -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) diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 5f9aec5..04d91f5 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -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)