Skip to content

Commit 370626f

Browse files
authored
Merge pull request #203 from derek73/rename-first-name-prefixes-to-bound-first-names
Rename first_name_prefixes to bound_first_names
2 parents d8a1d19 + fce4dda commit 370626f

10 files changed

Lines changed: 48 additions & 48 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Each module defines a plain Python set of known name pieces:
7676
- `titles.py``TITLES` (prenominals) and `FIRST_NAME_TITLES` (e.g. "Sir", which treat the following name as first, not last)
7777
- `suffixes.py``SUFFIX_ACRONYMS` (with periods, e.g. "M.D.") and `SUFFIX_NOT_ACRONYMS` (e.g. "Jr.")
7878
- `prefixes.py``PREFIXES` (lastname particles, e.g. "de", "van")
79-
- `first_name_prefixes.py``FIRST_NAME_PREFIXES` (bound given-name prefixes, e.g. "abdul", "abu"); `_join_first_name_prefix` joins the first non-title piece to its following piece before the main assignment loop
79+
- `bound_first_names.py``BOUND_FIRST_NAMES` (bound given-name prefixes, e.g. "abdul", "abu"); `_join_bound_first_name` joins the first non-title piece to its following piece before the main assignment loop
8080
- `conjunctions.py``CONJUNCTIONS` (e.g. "and", "of") used to chain multi-word titles
8181
- `capitalization.py``CAPITALIZATION_EXCEPTIONS` mapping (e.g. `{'phd': 'Ph.D.'}`)
8282
- `regexes.py` — compiled regular expressions wrapped in a `TupleManager`
@@ -97,7 +97,7 @@ Parse flow:
9797
3. `parse_pieces()` — splits on spaces, detects dotted abbreviations like "Lt.Gov." and adds them to constants dynamically
9898
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
9999
— 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)
100-
4a. `_join_first_name_prefix()` — 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
100+
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
101101
5. Iterates pieces, assigning to `title_list`, `first_list`, `middle_list`, `last_list`, `suffix_list`
102102
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.
103103

@@ -116,7 +116,7 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co
116116

117117
Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_deepcopy_roundtrip`/`test_nickname_delimiters_deepcopy_roundtrip` in `tests/test_constants.py`), not just reliance on conftest's autouse snapshot/restore exercising it incidentally. `TupleManager`/`RegexTupleManager.__getattr__` answer *any* unknown attribute lookup — including dunder probes like `__deepcopy__` — so a new manager subtype or a `__getattr__` tweak can silently break `copy.deepcopy` (this bit `RegexTupleManager` before the dunder-lookup guard was added). A direct test on the new attribute's own manager instance catches that where the conftest fixture, which never asserts on the copy, would not. As with the scalar-attribute pattern above, also check `AGENTS.md` itself for now-stale references when you touch this.
118118

119-
**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc``PREFIXES``TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu``PREFIXES``first_name_prefixes` (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `first_name_prefixes`).
119+
**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc``PREFIXES``TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu``PREFIXES``bound_first_names` (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `bound_first_names`).
120120

121121
**Before adding a short/common word to `PREFIXES` globally**, test it mid-string against realistic 3-token names, not just check for English-word collisions: Korean/Vietnamese given names put a short syllable in the middle slot (`Park In Hwan`, `Nguyen To Nga`), and Western names put a bare initial there (`John V. Smith`). A word that looks safe ("nobody is named 'to'") can still swallow a real middle name/initial into the last name once it's a global prefix — confirmed regressions for `to`/`in`/`an`/`ten`/`then` and bare `v` this way (PR #191).
122122

@@ -144,7 +144,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_
144144

145145
**`TupleManager.__setattr__`/`__delattr__` guard dunder names too, not just `__getattr__`** — constructing a subscripted generic, e.g. `TupleManager[re.Pattern[str] | str]({...})` (needed so mypy sees the right value type instead of inferring one from the dict literal), makes `typing`'s `GenericAlias.__call__` set `__orig_class__` on the new instance right after `__init__` returns. Before this guard existed, `__setattr__` was a bare `dict.__setitem__` alias, so that assignment silently inserted a bogus `'__orig_class__'` entry into the dict itself, corrupting `.values()`/iteration for *every* `TupleManager`/`RegexTupleManager` instance, not just the one being constructed — this bit `nickname_delimiters`'s construction (#22) before the guard was added. Same fix shape as the `__getattr__` dunder guard above: fall back to `object.__setattr__`/`object.__delattr__` for dunder names, dict-backed storage for everything else.
146146

147-
**`_join_first_name_prefix` guard must exclude trailing suffixes** — suffix tokens are still in `pieces` when the helper runs (suffix detection happens in the assignment loop, later). The `reserve_last` guard must count `if not self.is_suffix(p)` to avoid treating a trailing suffix like "Jr." as a last-name slot; otherwise `"abdul salam jr"``last='jr'`.
147+
**`_join_bound_first_name` guard must exclude trailing suffixes** — suffix tokens are still in `pieces` when the helper runs (suffix detection happens in the assignment loop, later). The `reserve_last` guard must count `if not self.is_suffix(p)` to avoid treating a trailing suffix like "Jr." as a last-name slot; otherwise `"abdul salam jr"``last='jr'`.
148148

149149
**Doctests** — docstring examples in `nameparser/*.py` run under `uv run pytest` (`--doctest-modules`; `testpaths` is `tests` + `nameparser` only). The `.rst` doctests in `docs/` (`usage.rst`, `customize.rst`) are **not** run by pytest or CI (CI does `sphinx-build -b html`, not `-b doctest`), so verify `.rst` examples manually: `python3 -c "import doctest; print(doctest.testfile('docs/usage.rst', module_relative=False, optionflags=doctest.NORMALIZE_WHITESPACE))"`. Note `customize.rst` has pre-existing failures under `-b doctest` (CONSTANTS state leaks across examples — no per-example reset like `tests/conftest.py` provides — plus non-deterministic `SetManager` repr).
150150

docs/customize.rst

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,10 @@ a secondary key::
260260

261261
sorted_names = sorted(names, key=lambda n: (n.last_base.lower(), n.first.lower()))
262262

263-
First-Name Prefixes
264-
-------------------
263+
Bound First Names
264+
------------------
265265

266-
``CONSTANTS.first_name_prefixes`` controls bound given-name prefixes that attach
266+
``CONSTANTS.bound_first_names`` controls bound given-name prefixes that attach
267267
to the following word to form one first name. By default it contains
268268
``{'abdul', 'abdel', 'abdal', 'abu', 'abou', 'umm'}``.
269269

@@ -277,20 +277,20 @@ Example::
277277
To **disable** the feature entirely::
278278

279279
>>> from nameparser.config import CONSTANTS
280-
>>> CONSTANTS.first_name_prefixes.clear()
280+
>>> CONSTANTS.bound_first_names.clear()
281281

282282
To **add** a word (e.g. if your data uses ``mohamad`` as a bound prefix)::
283283

284-
>>> CONSTANTS.first_name_prefixes.add('mohamad')
284+
>>> CONSTANTS.bound_first_names.add('mohamad')
285285

286286
To **remove** a single entry::
287287

288-
>>> CONSTANTS.first_name_prefixes.remove('umm')
288+
>>> CONSTANTS.bound_first_names.remove('umm')
289289

290290
You can also pass a custom set per ``Constants`` instance::
291291

292292
>>> from nameparser.config import Constants
293-
>>> c = Constants(first_name_prefixes={'abu', 'umm'})
293+
>>> c = Constants(bound_first_names={'abu', 'umm'})
294294
>>> hn2 = HumanName("abu bakr al saud", constants=c)
295295
>>> hn2.first, hn2.last
296296
('abu bakr', 'al saud')
@@ -310,7 +310,7 @@ Example::
310310
('', 'de Mesnil')
311311

312312
A member must be a prefix that is never a given name in any culture, and the set
313-
must stay **disjoint** from ``first_name_prefixes`` (a word cannot both join to
313+
must stay **disjoint** from ``bound_first_names`` (a word cannot both join to
314314
the first name and never be a first name). Ambiguous particles that *can* be
315315
given names (``van``, ``von``, ``della``, ``di``, ``del``, ...) are intentionally
316316
excluded; add them yourself if your data warrants it::

docs/release_log.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ Release Log
2929
- Fix suffix boundary lookup for prefixed last names with a title before and after (e.g. ``"dr Vincent van Gogh dr"`` producing a corrupted middle name) (closes #100)
3030
- Add ``patronymic_name_order`` flag to ``Constants`` and ``HumanName`` for opt-in detection and reordering of Russian formal-order names (Surname GivenName Patronymic) (#85)
3131
- Add Turkic (Azerbaijani/Central-Asian) patronymic detection to ``patronymic_name_order``, rotating the reversed 4-token formal shape (``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``) into Western order (#185)
32-
- Add ``first_name_prefixes`` set to ``Constants``; bound Arabic given-name
32+
- Add ``bound_first_names`` set to ``Constants``; bound Arabic given-name
3333
prefixes (``abdul``, ``abu``, etc.) now join forward to form a single first
3434
name (e.g. ``"abdul salam ahmed salem"`` → ``first="abdul salam"``,
3535
``middle="ahmed"``, ``last="salem"``). Disable via
36-
``CONSTANTS.first_name_prefixes.clear()``. **Default-on: changes parsing
36+
``CONSTANTS.bound_first_names.clear()``. **Default-on: changes parsing
3737
output for names with these prefixes.** (#150)
3838
- Add ``middle_name_as_last`` flag to ``Constants`` and ``HumanName`` for opt-in folding of middle names into the last name, for naming systems with no middle-name concept (e.g. Arabic patronymic chaining) (#133)
3939
- Treat an unrecognized, multi-letter token ending in a period in the leading title run (before the first name is set), e.g. ``"Major."``, as a ``title`` instead of a ``first`` name; internal-period abbreviations (``"E.T."``) and single-letter initials (``"J."``) are unaffected. **Default-on: changes parsing of names with a leading unknown period-abbreviation** (closes #109)

nameparser/config/__init__.py

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

3838
from nameparser.util import lc
3939
from nameparser.config.prefixes import PREFIXES, NON_FIRST_NAME_PREFIXES
40-
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES
40+
from nameparser.config.bound_first_names import BOUND_FIRST_NAMES
4141
from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS
4242
from nameparser.config.conjunctions import CONJUNCTIONS
4343
from nameparser.config.suffixes import SUFFIX_ACRONYMS
@@ -262,13 +262,13 @@ class Constants:
262262
:py:attr:`~suffixes.SUFFIX_ACRONYMS_AMBIGUOUS` wrapped with :py:class:`SetManager`.
263263
:param set conjunctions:
264264
:py:attr:`conjunctions` wrapped with :py:class:`SetManager`.
265-
:param set first_name_prefixes:
266-
:py:attr:`~first_name_prefixes.FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
265+
:param set bound_first_names:
266+
:py:attr:`~bound_first_names.BOUND_FIRST_NAMES` wrapped with :py:class:`SetManager`.
267267
:param set non_first_name_prefixes:
268268
:py:attr:`~prefixes.NON_FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
269269
The subset of prefixes that are never a first name, so a *leading* one
270270
marks the whole name as a surname. Must stay disjoint from
271-
``first_name_prefixes``.
271+
``bound_first_names``.
272272
:type capitalization_exceptions: tuple or dict
273273
:param capitalization_exceptions:
274274
:py:attr:`~capitalization.CAPITALIZATION_EXCEPTIONS` wrapped with :py:class:`TupleManager`.
@@ -293,7 +293,7 @@ class Constants:
293293
titles = _CachedUnionMember()
294294
first_name_titles: SetManager
295295
conjunctions: SetManager
296-
first_name_prefixes: SetManager
296+
bound_first_names: SetManager
297297
non_first_name_prefixes: SetManager
298298
suffix_acronyms_ambiguous: SetManager
299299
capitalization_exceptions: TupleManager[str]
@@ -468,7 +468,7 @@ def __init__(self,
468468
titles: Iterable[str] = TITLES,
469469
first_name_titles: Iterable[str] = FIRST_NAME_TITLES,
470470
conjunctions: Iterable[str] = CONJUNCTIONS,
471-
first_name_prefixes: Iterable[str] = FIRST_NAME_PREFIXES,
471+
bound_first_names: Iterable[str] = BOUND_FIRST_NAMES,
472472
non_first_name_prefixes: Iterable[str] = NON_FIRST_NAME_PREFIXES,
473473
capitalization_exceptions: TupleManager[str] | Iterable[tuple[str, str]] = CAPITALIZATION_EXCEPTIONS,
474474
regexes: RegexTupleManager | TupleManager[re.Pattern[str]] | Iterable[tuple[str, re.Pattern[str]]] = REGEXES,
@@ -484,7 +484,7 @@ def __init__(self,
484484
self.titles = SetManager(titles)
485485
self.first_name_titles = SetManager(first_name_titles)
486486
self.conjunctions = SetManager(conjunctions)
487-
self.first_name_prefixes = SetManager(first_name_prefixes)
487+
self.bound_first_names = SetManager(bound_first_names)
488488
self.non_first_name_prefixes = SetManager(non_first_name_prefixes)
489489
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
490490
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#: one first name (e.g. "abdul salam" → first name "abdul salam"). They are
33
#: never standalone names. Join logic runs in the given-name region only,
44
#: mirroring :py:data:`~nameparser.config.prefixes.PREFIXES` for last names.
5-
FIRST_NAME_PREFIXES: set[str] = {
5+
BOUND_FIRST_NAMES: set[str] = {
66
'abdul',
77
'abdel',
88
'abdal',

nameparser/config/prefixes.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES
1+
from nameparser.config.bound_first_names import BOUND_FIRST_NAMES
22

33
#: The sub-set of :py:data:`PREFIXES` that are *never* a standalone first name.
44
#: A name that *starts* with one of these has no first name -- the whole thing
@@ -8,7 +8,7 @@
88
#: name prefix (`abu`). When unsure, leave a word out: a missing member just
99
#: means that name is not auto-fixed, whereas a wrong member misparses a real
1010
#: person. Must stay a subset of :py:data:`PREFIXES` and disjoint from
11-
#: :py:data:`~nameparser.config.first_name_prefixes.FIRST_NAME_PREFIXES`.
11+
#: :py:data:`~nameparser.config.bound_first_names.BOUND_FIRST_NAMES`.
1212
NON_FIRST_NAME_PREFIXES = set([
1313
"'t",
1414
'af',
@@ -93,5 +93,5 @@
9393
# happens to catch it.
9494
assert NON_FIRST_NAME_PREFIXES <= PREFIXES, \
9595
"NON_FIRST_NAME_PREFIXES must stay a subset of PREFIXES"
96-
assert not (NON_FIRST_NAME_PREFIXES & FIRST_NAME_PREFIXES), \
97-
"NON_FIRST_NAME_PREFIXES must stay disjoint from FIRST_NAME_PREFIXES"
96+
assert not (NON_FIRST_NAME_PREFIXES & BOUND_FIRST_NAMES), \
97+
"NON_FIRST_NAME_PREFIXES must stay disjoint from BOUND_FIRST_NAMES"

nameparser/parser.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -595,26 +595,26 @@ def is_prefix(self, piece: str) -> bool:
595595
else:
596596
return lc(piece) in self.C.prefixes
597597

598-
def is_first_name_prefix(self, piece: str) -> bool:
599-
"""Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.first_name_prefixes`."""
600-
return lc(piece) in self.C.first_name_prefixes
598+
def is_bound_first_name(self, piece: str) -> bool:
599+
"""Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.bound_first_names`."""
600+
return lc(piece) in self.C.bound_first_names
601601

602602
def is_non_first_name_prefix(self, piece: str) -> bool:
603603
"""Lowercased, leading/trailing-periods-stripped version of piece is in
604604
:py:attr:`~nameparser.config.Constants.non_first_name_prefixes`."""
605605
return lc(piece) in self.C.non_first_name_prefixes
606606

607-
def _join_first_name_prefix(self, pieces: list[str], reserve_last: bool) -> list[str]:
607+
def _join_bound_first_name(self, pieces: list[str], reserve_last: bool) -> list[str]:
608608
"""Join a first-name prefix to its following piece.
609609
610-
Finds the first non-title piece; if it is in ``first_name_prefixes``,
610+
Finds the first non-title piece; if it is in ``bound_first_names``,
611611
merges it with the next piece — unless ``reserve_last`` is True and no
612612
further piece would remain for the last name.
613613
"""
614614
fi = next((i for i, p in enumerate(pieces) if not self.is_title(p)), None)
615615
if fi is None:
616616
return pieces
617-
if not self.is_first_name_prefix(pieces[fi]):
617+
if not self.is_bound_first_name(pieces[fi]):
618618
return pieces
619619
next_i = fi + 1
620620
if next_i >= len(pieces):
@@ -1038,7 +1038,7 @@ def parse_full_name(self) -> None:
10381038
# part[0]
10391039

10401040
pieces = self.parse_pieces(parts)
1041-
pieces = self._join_first_name_prefix(pieces, reserve_last=True)
1041+
pieces = self._join_bound_first_name(pieces, reserve_last=True)
10421042
p_len = len(pieces)
10431043
for i, piece in enumerate(pieces):
10441044
try:
@@ -1121,7 +1121,7 @@ def parse_full_name(self) -> None:
11211121
# parts[0], parts[1], parts[2:...]
11221122

11231123
log.debug("post-comma pieces: %s", str(post_comma_pieces))
1124-
post_comma_pieces = self._join_first_name_prefix(post_comma_pieces, reserve_last=False)
1124+
post_comma_pieces = self._join_bound_first_name(post_comma_pieces, reserve_last=False)
11251125

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

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"titles",
3535
"first_name_titles",
3636
"conjunctions",
37-
"first_name_prefixes",
37+
"bound_first_names",
3838
"non_first_name_prefixes",
3939
"capitalization_exceptions",
4040
"regexes",

0 commit comments

Comments
 (0)