Skip to content

Commit c84b1cd

Browse files
derek73claude
andauthored
feat: add first_name_prefixes for Arabic given-name prefix joining (#150) (#186)
* feat: add first_name_prefixes set to Constants (#150) * feat: add is_first_name_prefix method to HumanName (#150) * feat: implement first-name prefix joining in no-comma parse path (#150) * feat: wire first-name prefix join into lastname-comma path (#150) * test: complete edge-case coverage for first-name prefix join (#150) * docs: document first_name_prefixes and add release note (#150) * fix: guard first-name prefix join against trailing suffixes (#150) The reserve_last guard previously counted suffix tokens as potential last-name candidates, causing "abdul salam jr" to parse as first="abdul salam", last="jr" instead of first="abdul", last="salam", suffix="jr". Fix: count only non-suffix pieces from next_i onward; require ≥2 so the join target and at least one non-suffix last-name piece both exist. * fix: guard _on_change in add_with_encoding/clear against no-op mutations; correct lc() docstrings - add_with_encoding and clear now only fire _on_change when the set actually changes, consistent with remove()'s existing changed guard - Correct 'no periods' to 'leading/trailing-periods-stripped' in lc(), is_prefix, is_first_name_prefix, and add_with_encoding docstrings * test: remove test_default_set_contents (parsing tests cover membership) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2a0c11b commit c84b1cd

9 files changed

Lines changed: 238 additions & 9 deletions

File tree

docs/customize.rst

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,41 @@ a secondary key::
131131

132132
sorted_names = sorted(names, key=lambda n: (n.last_base.lower(), n.first.lower()))
133133

134+
First-Name Prefixes
135+
-------------------
136+
137+
``CONSTANTS.first_name_prefixes`` controls bound given-name prefixes that attach
138+
to the following word to form one first name. By default it contains
139+
``{'abdul', 'abdel', 'abdal', 'abu', 'abou', 'umm'}``.
140+
141+
Example::
142+
143+
>>> from nameparser import HumanName
144+
>>> hn = HumanName("abdul salam ahmed salem")
145+
>>> hn.first, hn.middle, hn.last
146+
('abdul salam', 'ahmed', 'salem')
147+
148+
To **disable** the feature entirely::
149+
150+
>>> from nameparser.config import CONSTANTS
151+
>>> CONSTANTS.first_name_prefixes.clear()
152+
153+
To **add** a word (e.g. if your data uses ``mohamad`` as a bound prefix)::
154+
155+
>>> CONSTANTS.first_name_prefixes.add('mohamad')
156+
157+
To **remove** a single entry::
158+
159+
>>> CONSTANTS.first_name_prefixes.remove('umm')
160+
161+
You can also pass a custom set per ``Constants`` instance::
162+
163+
>>> from nameparser.config import Constants
164+
>>> c = Constants(first_name_prefixes={'abu', 'umm'})
165+
>>> hn2 = HumanName("abu bakr al saud", constants=c)
166+
>>> hn2.first, hn2.last
167+
('abu bakr', 'al saud')
168+
134169
Parser Customization Examples
135170
-----------------------------
136171

@@ -181,7 +216,7 @@ constant so that "Hon" can be parsed as a first name.
181216

182217
If you don't want to detect any titles at all, you can remove all of them:
183218

184-
>>> CONSTANTS.titles.remove(*CONSTANTS.titles)
219+
>>> CONSTANTS.titles.clear()
185220

186221

187222
Adding a Title

docs/release_log.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ Release Log
2020
- Fix ``'apn aprn'`` split into separate ``suffix_acronyms`` entries so each is recognized independently (closes #155)
2121
- Add ``last_base``, ``last_prefixes`` (and ``_list`` variants) plus ``family`` / ``family_prefixes`` aliases for splitting last-name prefix particles (tussenvoegsels) from the core surname (#130, #132)
2222
- Add ``patronymic_name_order`` flag to ``Constants`` and ``HumanName`` for opt-in detection and reordering of Russian formal-order names (Surname GivenName Patronymic) (#85)
23+
- Add ``first_name_prefixes`` set to ``Constants``; bound Arabic given-name
24+
prefixes (``abdul``, ``abu``, etc.) now join forward to form a single first
25+
name (e.g. ``"abdul salam ahmed salem"`` → ``first="abdul salam"``,
26+
``middle="ahmed"``, ``last="salem"``). Disable via
27+
``CONSTANTS.first_name_prefixes.clear()``. **Default-on: changes parsing
28+
output for names with these prefixes.** (#150)
2329
* 1.2.1 - June 19, 2026
2430
- Fix ``initials()`` interpolating the literal ``None`` for empty name parts when ``empty_attribute_default = None`` (e.g. ``"J. None D."``); empty parts now render as an empty string and a fully-empty result returns ``empty_attribute_default``
2531
- Add ``python -m nameparser "Name String"`` command-line helper that prints a parsed name

nameparser/config/__init__.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838
from nameparser.util import lc
3939
from nameparser.config.prefixes import PREFIXES
40+
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES
4041
from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS
4142
from nameparser.config.conjunctions import CONJUNCTIONS
4243
from nameparser.config.suffixes import SUFFIX_ACRONYMS
@@ -86,7 +87,7 @@ def __len__(self) -> int:
8687

8788
def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
8889
"""
89-
Add the lower case and no-period version of the string to the set. Pass an
90+
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
9091
explicit `encoding` parameter to specify the encoding of binary strings that
9192
are not DEFAULT_ENCODING (UTF-8).
9293
"""
@@ -96,13 +97,15 @@ def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
9697
encoding = encoding or stdin_encoding or DEFAULT_ENCODING
9798
if isinstance(s, bytes):
9899
s = s.decode(encoding)
99-
self.elements.add(lc(s))
100-
if self._on_change:
101-
self._on_change()
100+
normalized = lc(s)
101+
if normalized not in self.elements:
102+
self.elements.add(normalized)
103+
if self._on_change:
104+
self._on_change()
102105

103106
def add(self, *strings: str) -> Self:
104107
"""
105-
Add the lower case and no-period version of the string arguments to the set.
108+
Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set.
106109
Can pass a list of strings. Returns ``self`` for chaining.
107110
"""
108111
for s in strings:
@@ -124,6 +127,14 @@ def remove(self, *strings: str) -> Self:
124127
self._on_change()
125128
return self
126129

130+
def clear(self) -> Self:
131+
"""Remove all entries from the set. Returns ``self`` for chaining."""
132+
if self.elements:
133+
self.elements.clear()
134+
if self._on_change:
135+
self._on_change()
136+
return self
137+
127138

128139
T = TypeVar('T')
129140

@@ -227,8 +238,10 @@ class Constants:
227238
:py:attr:`~suffixes.SUFFIX_ACRONYMS` wrapped with :py:class:`SetManager`.
228239
:param set suffix_not_acronyms:
229240
:py:attr:`~suffixes.SUFFIX_NOT_ACRONYMS` wrapped with :py:class:`SetManager`.
230-
:param set conjunctions:
241+
:param set conjunctions:
231242
:py:attr:`conjunctions` wrapped with :py:class:`SetManager`.
243+
:param set first_name_prefixes:
244+
:py:attr:`~first_name_prefixes.FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
232245
:type capitalization_exceptions: tuple or dict
233246
:param capitalization_exceptions:
234247
:py:attr:`~capitalization.CAPITALIZATION_EXCEPTIONS` wrapped with :py:class:`TupleManager`.
@@ -243,6 +256,7 @@ class Constants:
243256
titles = _CachedUnionMember()
244257
first_name_titles: SetManager
245258
conjunctions: SetManager
259+
first_name_prefixes: SetManager
246260
capitalization_exceptions: TupleManager[str]
247261
regexes: RegexTupleManager
248262
_pst: Set[str] | None
@@ -377,6 +391,7 @@ def __init__(self,
377391
titles: Iterable[str] = TITLES,
378392
first_name_titles: Iterable[str] = FIRST_NAME_TITLES,
379393
conjunctions: Iterable[str] = CONJUNCTIONS,
394+
first_name_prefixes: Iterable[str] = FIRST_NAME_PREFIXES,
380395
capitalization_exceptions: TupleManager[str] | Iterable[tuple[str, str]] = CAPITALIZATION_EXCEPTIONS,
381396
regexes: RegexTupleManager | TupleManager[re.Pattern[str]] | Iterable[tuple[str, re.Pattern[str]]] = REGEXES,
382397
patronymic_name_order: bool = False,
@@ -390,6 +405,7 @@ def __init__(self,
390405
self.titles = SetManager(titles)
391406
self.first_name_titles = SetManager(first_name_titles)
392407
self.conjunctions = SetManager(conjunctions)
408+
self.first_name_prefixes = SetManager(first_name_prefixes)
393409
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
394410
self.regexes = RegexTupleManager(regexes)
395411
self.patronymic_name_order = patronymic_name_order
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#: Bound Arabic given-name prefixes that attach to the following word to form
2+
#: one first name (e.g. "abdul salam" → first name "abdul salam"). They are
3+
#: never standalone names. Join logic runs in the given-name region only,
4+
#: mirroring :py:data:`~nameparser.config.prefixes.PREFIXES` for last names.
5+
FIRST_NAME_PREFIXES: set[str] = {
6+
'abdul',
7+
'abdel',
8+
'abdal',
9+
'abu',
10+
'abou',
11+
'umm',
12+
}

nameparser/parser.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ def is_conjunction(self, piece: str) -> bool:
551551

552552
def is_prefix(self, piece: str) -> bool:
553553
"""
554-
Lowercase and no periods version of piece is in the
554+
Lowercased, leading/trailing-periods-stripped version of piece is in the
555555
:py:data:`~nameparser.config.prefixes.PREFIXES` set.
556556
"""
557557
if isinstance(piece, list):
@@ -561,6 +561,37 @@ def is_prefix(self, piece: str) -> bool:
561561
else:
562562
return lc(piece) in self.C.prefixes
563563

564+
def is_first_name_prefix(self, piece: str) -> bool:
565+
"""Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.first_name_prefixes`."""
566+
return lc(piece) in self.C.first_name_prefixes
567+
568+
def _join_first_name_prefix(self, pieces: list[str], reserve_last: bool) -> list[str]:
569+
"""Join a first-name prefix to its following piece.
570+
571+
Finds the first non-title piece; if it is in ``first_name_prefixes``,
572+
merges it with the next piece — unless ``reserve_last`` is True and no
573+
further piece would remain for the last name.
574+
"""
575+
fi = next((i for i, p in enumerate(pieces) if not self.is_title(p)), None)
576+
if fi is None:
577+
return pieces
578+
if not self.is_first_name_prefix(pieces[fi]):
579+
return pieces
580+
next_i = fi + 1
581+
if next_i >= len(pieces):
582+
return pieces
583+
if reserve_last:
584+
# Count non-suffix pieces from next_i onward; need ≥2 so the join
585+
# target and at least one last-name piece both exist.
586+
non_suffix_remaining = sum(
587+
1 for p in pieces[next_i:] if not self.is_suffix(p)
588+
)
589+
if non_suffix_remaining <= 1:
590+
return pieces
591+
pieces[fi] = pieces[fi] + " " + pieces[next_i]
592+
del pieces[next_i]
593+
return pieces
594+
564595
def is_roman_numeral(self, value: str) -> bool:
565596
"""
566597
Matches the ``roman_numeral`` regular expression in
@@ -827,6 +858,7 @@ def parse_full_name(self) -> None:
827858
# part[0]
828859

829860
pieces = self.parse_pieces(parts)
861+
pieces = self._join_first_name_prefix(pieces, reserve_last=True)
830862
p_len = len(pieces)
831863
for i, piece in enumerate(pieces):
832864
try:
@@ -909,6 +941,7 @@ def parse_full_name(self) -> None:
909941
# parts[0], parts[1], parts[2:...]
910942

911943
log.debug("post-comma pieces: %s", str(post_comma_pieces))
944+
post_comma_pieces = self._join_first_name_prefix(post_comma_pieces, reserve_last=False)
912945

913946
# lastname part may have suffixes in it
914947
lastname_pieces = self.parse_pieces(parts[0].split(' '), 1)

nameparser/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
def lc(value: str) -> str:
14-
"""Lower case and remove any periods to normalize for comparison."""
14+
"""Lowercase and strip leading/trailing periods to normalize for comparison."""
1515
if not value:
1616
return ''
1717
return value.lower().strip('.')

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"titles",
3333
"first_name_titles",
3434
"conjunctions",
35+
"first_name_prefixes",
3536
"capitalization_exceptions",
3637
"regexes",
3738
)

tests/test_constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ def test_chain_multiple_arguments(self) -> None:
7777
self.m(hn.middle, "Hon", hn)
7878
self.m(hn.last, "Solo", hn)
7979

80+
def test_clear_removes_all_entries(self) -> None:
81+
hn = HumanName("Ms Hon Solo", constants=None)
82+
hn.C.titles.clear()
83+
hn.parse_full_name()
84+
self.m(hn.first, "Ms", hn)
85+
self.m(hn.middle, "Hon", hn)
86+
self.m(hn.last, "Solo", hn)
87+
8088
def test_empty_attribute_default(self) -> None:
8189
from nameparser.config import CONSTANTS
8290
_orig = CONSTANTS.empty_attribute_default

tests/test_first_name_prefixes.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from nameparser import HumanName
2+
from tests.base import HumanNameTestBase
3+
4+
5+
class FirstNamePrefixesTestCase(HumanNameTestBase):
6+
7+
def test_is_first_name_prefix_true(self) -> None:
8+
hn = HumanName("test")
9+
assert hn.is_first_name_prefix("Abdul")
10+
11+
def test_is_first_name_prefix_false(self) -> None:
12+
hn = HumanName("test")
13+
assert not hn.is_first_name_prefix("Ahmed")
14+
15+
# --- no-comma: basic joining ---
16+
def test_no_comma_basic_join(self) -> None:
17+
hn = HumanName("abdul salam ahmed salem")
18+
self.m(hn.first, "abdul salam", hn)
19+
self.m(hn.middle, "ahmed", hn)
20+
self.m(hn.last, "salem", hn)
21+
22+
def test_no_comma_three_tokens_no_middle(self) -> None:
23+
hn = HumanName("abdul salam salem")
24+
self.m(hn.first, "abdul salam", hn)
25+
self.m(hn.middle, "", hn)
26+
self.m(hn.last, "salem", hn)
27+
28+
def test_no_comma_guard_two_tokens_no_join(self) -> None:
29+
"""Guard: only last name remains after prefix → no join."""
30+
hn = HumanName("abdul salam")
31+
self.m(hn.first, "abdul", hn)
32+
self.m(hn.last, "salam", hn)
33+
34+
def test_no_comma_guard_suffix_not_swallowed(self) -> None:
35+
"""Guard: prefix + one name + suffix — suffix must not become last."""
36+
hn = HumanName("abdul salam jr")
37+
self.m(hn.first, "abdul", hn)
38+
self.m(hn.last, "salam", hn)
39+
self.m(hn.suffix, "jr", hn)
40+
41+
# --- lastname-comma path ---
42+
def test_lastname_comma_join(self) -> None:
43+
hn = HumanName("salem, abdul salam")
44+
self.m(hn.first, "abdul salam", hn)
45+
self.m(hn.middle, "", hn)
46+
self.m(hn.last, "salem", hn)
47+
48+
def test_lastname_comma_join_with_middle(self) -> None:
49+
hn = HumanName("salem, abdul salam ahmed")
50+
self.m(hn.first, "abdul salam", hn)
51+
self.m(hn.middle, "ahmed", hn)
52+
self.m(hn.last, "salem", hn)
53+
54+
# --- interaction with titles ---
55+
def test_title_kept_prefix_joins(self) -> None:
56+
hn = HumanName("Dr. abdul salam ahmed salem")
57+
self.m(hn.title, "Dr.", hn)
58+
self.m(hn.first, "abdul salam", hn)
59+
self.m(hn.middle, "ahmed", hn)
60+
self.m(hn.last, "salem", hn)
61+
62+
# --- interaction with last-name prefixes ---
63+
def test_abu_bakr_al_baghdadi(self) -> None:
64+
"""abu joins forward as first-prefix; al joins forward as last-prefix."""
65+
hn = HumanName("abu bakr al baghdadi")
66+
self.m(hn.first, "abu bakr", hn)
67+
self.m(hn.last, "al baghdadi", hn)
68+
69+
# --- interaction with suffixes ---
70+
def test_suffix_kept_prefix_joins(self) -> None:
71+
hn = HumanName("abdul salam ahmed salem jr")
72+
self.m(hn.first, "abdul salam", hn)
73+
self.m(hn.middle, "ahmed", hn)
74+
self.m(hn.last, "salem", hn)
75+
self.m(hn.suffix, "jr", hn)
76+
77+
# --- guard / no-op ---
78+
def test_mohamad_unchanged(self) -> None:
79+
"""mohamad is deliberately not in first_name_prefixes."""
80+
hn = HumanName("Mohamad Ali Khalil")
81+
self.m(hn.first, "Mohamad", hn)
82+
self.m(hn.middle, "Ali", hn)
83+
self.m(hn.last, "Khalil", hn)
84+
85+
def test_single_token_already_joined_unchanged(self) -> None:
86+
"""abdulsalam is one token — not in the set, no join."""
87+
hn = HumanName("abdulsalam ahmed salem")
88+
self.m(hn.first, "abdulsalam", hn)
89+
self.m(hn.middle, "ahmed", hn)
90+
self.m(hn.last, "salem", hn)
91+
92+
def test_prefix_alone_no_join(self) -> None:
93+
"""Single-word name that is a prefix: nothing to join."""
94+
hn = HumanName("abdul")
95+
self.m(hn.first, "abdul", hn)
96+
97+
def test_lastname_comma_prefix_only_no_join(self) -> None:
98+
"""Prefix as sole post-comma token: nothing to join."""
99+
hn = HumanName("salem, abdul")
100+
self.m(hn.first, "abdul", hn)
101+
self.m(hn.last, "salem", hn)
102+
103+
def test_mid_name_prefix_becomes_last_prefix(self) -> None:
104+
"""abu in non-first position is handled as a last-name prefix, not first-name."""
105+
hn = HumanName("ahmed abu bakr")
106+
self.m(hn.first, "ahmed", hn)
107+
self.m(hn.last, "abu bakr", hn)
108+
109+
# --- opt-out ---
110+
def test_opt_out_via_clear(self) -> None:
111+
"""Clearing first_name_prefixes restores prior behavior."""
112+
from nameparser.config import Constants
113+
c = Constants(first_name_prefixes=set())
114+
hn = HumanName("abdul salam ahmed salem", constants=c)
115+
self.m(hn.first, "abdul", hn)
116+
self.m(hn.middle, "salam ahmed", hn)
117+
self.m(hn.last, "salem", hn)
118+

0 commit comments

Comments
 (0)