From 38c4944e6b778289c151c4dcf457305a4146579b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 20:22:56 -0700 Subject: [PATCH 1/2] Fix empty string piece leaking into first_list for comma-only input HumanName(',') (no-comma path after collapse_whitespace strips the trailing comma) and HumanName('Doe,, Jr.') (lastname-comma path) both appended '' to first_list. Filter tokens that strip to nothing in parse_pieces, the common source feeding all three parse paths, so no empty piece reaches the parse loops or the public *_list attributes. Co-Authored-By: Claude Fable 5 --- docs/release_log.rst | 1 + nameparser/parser.py | 5 ++++- tests/test_python_api.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/release_log.rst b/docs/release_log.rst index 4b2c540..503e33f 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-only input (e.g. ``","``, ``"Doe,, Jr."``) leaving an empty-string member in ``first_list`` - 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..cb0e6ea 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -1183,7 +1183,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..fce0f7d 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -294,6 +294,18 @@ 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(len(hn), 0) + hn = HumanName("Doe,, Jr.") + self.assertEqual(hn.first_list, []) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "Jr.", hn) + def test_blank_name(self) -> None: hn = HumanName() self.m(hn.first, "", hn) From ae9bcc9a9f458252ff4eaeb0c6b3bcd7cb0b8381 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 20:37:29 -0700 Subject: [PATCH 2/2] Address review: guard suffix-comma path, pin setter filtering, docs - Add the missing `if part` guard to the suffix-comma parts[1:] loop: "John Doe, Jr.,," leaked '' into suffix_list via expand_suffix_delimiter('') returning [''], the same bug class this branch fixes elsewhere (regression test added) - Pin the setter-path filtering as intended behavior: whitespace-only and empty tokens assigned via attributes, lists, or __setitem__ no longer become *_list members - Pin the lastname_pieces call site: ", John" leaves last_list empty - Assert sibling lists for HumanName(',') so a relocated (not removed) empty piece can't pass - State the no-empty-pieces guarantee in parse_pieces()'s docstring and broaden the release log entry to cover last_list/suffix_list and the setter behavior - Align the suffix-comma "final piece" comment with the no-comma site's wording (the PR 216 replace_all missed this deeper-indented copy) Co-Authored-By: Claude Fable 5 --- docs/release_log.rst | 2 +- nameparser/parser.py | 16 +++++++++++----- tests/test_python_api.py | 23 +++++++++++++++++++++++ tests/test_suffixes.py | 10 ++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/release_log.rst b/docs/release_log.rst index 503e33f..373e43e 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -7,7 +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-only input (e.g. ``","``, ``"Doe,, Jr."``) leaving an empty-string member in ``first_list`` + - 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 cb0e6ea..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. diff --git a/tests/test_python_api.py b/tests/test_python_api.py index fce0f7d..aaf4661 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -300,11 +300,34 @@ def test_degenerate_comma_input_leaves_no_empty_pieces(self) -> None: # 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() 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)