From 8b2179f6bed9f69f4c362c21fba501ddfc137ef9 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 02:14:51 -0700 Subject: [PATCH 1/2] Fix missing bound-first-name join in suffix-comma branch parse_full_name() called _join_bound_first_name() in the no-comma and lastname-comma branches but not in the suffix-comma branch, so HumanName("Abdul Salam Hassan, MD") split the bound first name across first/middle while the equivalent no-comma and lastname-comma forms of the same name did not. Add the missing call and regression tests. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 2 +- nameparser/parser.py | 1 + tests/test_bound_first_names.py | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8f5b865..28046a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Parse flow: 3. `parse_pieces()` — splits on spaces, detects dotted abbreviations like "Lt.Gov." and adds them to constants dynamically 4. `join_on_conjunctions()` — merges pieces adjacent to conjunctions into single tokens (e.g. `['Secretary', 'of', 'State']` → `['Secretary of State']`); also joins prefix particles to the following lastname token — a piece merged with a title *or prefix* neighbor is re-registered into that constant set so later steps still recognize it, e.g. `von`+`und`+`zu` → registered as a prefix so the whole phrase joins to the last name (German "von und zu"; PR #191) -4a. `_join_bound_first_name()` — called immediately after step 4 in both the no-comma and lastname-comma paths; merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still in `pieces` at this point, so the `reserve_last` guard must count non-suffix pieces only +4a. `_join_bound_first_name()` — called immediately after step 4 in all three paths that build a first-name-bearing token sequence: no-comma, lastname-comma (post-comma pieces), and suffix-comma (`parts[0]`); merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still in `pieces` at this point, so the `reserve_last` guard must count non-suffix pieces only. Not called for the lastname portion itself (`lastname_pieces` in the lastname-comma path) — that token sequence is a surname, not first-name text, so the join must not apply there. This is why the join lives at each of these three call sites individually rather than inside `parse_pieces()` itself: `parse_pieces()` is also called for the lastname portion with the same `additional_parts_count` value used for the (joinable) post-comma given-names portion, so `additional_parts_count` alone can't disambiguate which callers want the join. 5. Iterates pieces, assigning to `title_list`, `first_list`, `middle_list`, `last_list`, `suffix_list` 6. `post_process()` — `handle_firstnames()` swaps first/last when only a title + one name; then, gated on `patronymic_name_order`, `handle_east_slavic_patronymic_name_order()` and `handle_turkic_patronymic_name_order()` reorder Russian-formal-order and reversed Turkic patronymics; then, gated on `middle_name_as_last`, `handle_middle_name_as_last()` folds `middle_list` into `last_list`; finally `handle_capitalization()` applies optional auto-cap. Any new `self._attr` used by `post_process()` helpers must be initialized in `__init__` (with its default value) — the direct-kwargs path bypasses `parse_full_name()`, so the attribute won't exist otherwise. diff --git a/nameparser/parser.py b/nameparser/parser.py index f170108..a2bc9c0 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -1072,6 +1072,7 @@ def parse_full_name(self) -> None: self.suffix_list += parts[1:] pieces = self.parse_pieces(parts[0].split(' ')) + pieces = self._join_bound_first_name(pieces, reserve_last=True) log.debug("pieces: %s", str(pieces)) for i, piece in enumerate(pieces): try: diff --git a/tests/test_bound_first_names.py b/tests/test_bound_first_names.py index 91eab32..aa50582 100644 --- a/tests/test_bound_first_names.py +++ b/tests/test_bound_first_names.py @@ -106,6 +106,28 @@ def test_mid_name_prefix_becomes_last_prefix(self) -> None: self.m(hn.first, "ahmed", hn) self.m(hn.last, "abu bakr", hn) + # --- suffix-comma path --- + def test_suffix_comma_join(self) -> None: + hn = HumanName("Abdul Salam Hassan, MD") + self.m(hn.first, "Abdul Salam", hn) + self.m(hn.middle, "", hn) + self.m(hn.last, "Hassan", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_join_with_middle(self) -> None: + hn = HumanName("Abdul Salam Ahmed Salem, MD") + self.m(hn.first, "Abdul Salam", hn) + self.m(hn.middle, "Ahmed", hn) + self.m(hn.last, "Salem", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_guard_two_tokens_no_join(self) -> None: + """Guard: only last name remains after prefix → no join, even with suffix comma.""" + hn = HumanName("Abdul Salam, MD") + self.m(hn.first, "Abdul", hn) + self.m(hn.last, "Salam", hn) + self.m(hn.suffix, "MD", hn) + # --- opt-out --- def test_opt_out_via_clear(self) -> None: """Clearing bound_first_names restores prior behavior.""" From 67621fed5e7732ff5b36fbd18e1c9de07f8922b9 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 02:18:34 -0700 Subject: [PATCH 2/2] Add suffix-comma title/last-prefix tests; split dense AGENTS.md sentence Reviewer follow-ups on PR #205: add regression tests for title and last-name-prefix interaction in the suffix-comma branch, mirroring existing coverage for the no-comma branch, and split a run-on sentence in AGENTS.md's parse-flow notes for readability. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 2 +- tests/test_bound_first_names.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 28046a5..a314f72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Parse flow: 3. `parse_pieces()` — splits on spaces, detects dotted abbreviations like "Lt.Gov." and adds them to constants dynamically 4. `join_on_conjunctions()` — merges pieces adjacent to conjunctions into single tokens (e.g. `['Secretary', 'of', 'State']` → `['Secretary of State']`); also joins prefix particles to the following lastname token — a piece merged with a title *or prefix* neighbor is re-registered into that constant set so later steps still recognize it, e.g. `von`+`und`+`zu` → registered as a prefix so the whole phrase joins to the last name (German "von und zu"; PR #191) -4a. `_join_bound_first_name()` — called immediately after step 4 in all three paths that build a first-name-bearing token sequence: no-comma, lastname-comma (post-comma pieces), and suffix-comma (`parts[0]`); merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still in `pieces` at this point, so the `reserve_last` guard must count non-suffix pieces only. Not called for the lastname portion itself (`lastname_pieces` in the lastname-comma path) — that token sequence is a surname, not first-name text, so the join must not apply there. This is why the join lives at each of these three call sites individually rather than inside `parse_pieces()` itself: `parse_pieces()` is also called for the lastname portion with the same `additional_parts_count` value used for the (joinable) post-comma given-names portion, so `additional_parts_count` alone can't disambiguate which callers want the join. +4a. `_join_bound_first_name()` — called immediately after step 4 in all three paths that build a first-name-bearing token sequence: no-comma, lastname-comma (post-comma pieces), and suffix-comma (`parts[0]`); merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still in `pieces` at this point, so the `reserve_last` guard must count non-suffix pieces only. Not called for the lastname portion itself (`lastname_pieces` in the lastname-comma path) — that token sequence is a surname, not first-name text, so the join must not apply there. The join lives at each of these three call sites individually rather than inside `parse_pieces()` itself. That's because `parse_pieces()` is also called for the lastname portion with the same `additional_parts_count` value used for the (joinable) post-comma given-names portion, so `additional_parts_count` alone can't disambiguate which callers want the join. 5. Iterates pieces, assigning to `title_list`, `first_list`, `middle_list`, `last_list`, `suffix_list` 6. `post_process()` — `handle_firstnames()` swaps first/last when only a title + one name; then, gated on `patronymic_name_order`, `handle_east_slavic_patronymic_name_order()` and `handle_turkic_patronymic_name_order()` reorder Russian-formal-order and reversed Turkic patronymics; then, gated on `middle_name_as_last`, `handle_middle_name_as_last()` folds `middle_list` into `last_list`; finally `handle_capitalization()` applies optional auto-cap. Any new `self._attr` used by `post_process()` helpers must be initialized in `__init__` (with its default value) — the direct-kwargs path bypasses `parse_full_name()`, so the attribute won't exist otherwise. diff --git a/tests/test_bound_first_names.py b/tests/test_bound_first_names.py index aa50582..cb5aae9 100644 --- a/tests/test_bound_first_names.py +++ b/tests/test_bound_first_names.py @@ -128,6 +128,20 @@ def test_suffix_comma_guard_two_tokens_no_join(self) -> None: self.m(hn.last, "Salam", hn) self.m(hn.suffix, "MD", hn) + def test_suffix_comma_title_kept_prefix_joins(self) -> None: + hn = HumanName("Dr. Abdul Salam Hassan, MD") + self.m(hn.title, "Dr.", hn) + self.m(hn.first, "Abdul Salam", hn) + self.m(hn.last, "Hassan", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_abu_bakr_al_baghdadi(self) -> None: + """abu joins forward as first-prefix; al joins forward as last-prefix, even with suffix comma.""" + hn = HumanName("Abu Bakr Al Baghdadi, MD") + self.m(hn.first, "Abu Bakr", hn) + self.m(hn.last, "Al Baghdadi", hn) + self.m(hn.suffix, "MD", hn) + # --- opt-out --- def test_opt_out_via_clear(self) -> None: """Clearing bound_first_names restores prior behavior."""