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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
1 change: 1 addition & 0 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions tests/test_bound_first_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,42 @@ 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)

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."""
Expand Down