Skip to content

Commit d8a1d19

Browse files
authored
Merge pull request #202 from derek73/worktree-non-first-name-prefixes
Fold a leading non-first-name prefix into the surname (closes #121)
2 parents e3d8b14 + ec876c3 commit d8a1d19

7 files changed

Lines changed: 250 additions & 24 deletions

File tree

docs/customize.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,33 @@ You can also pass a custom set per ``Constants`` instance::
295295
>>> hn2.first, hn2.last
296296
('abu bakr', 'al saud')
297297

298+
Non-First-Name Prefixes
299+
-----------------------
300+
301+
``CONSTANTS.non_first_name_prefixes`` is the subset of prefixes that are *never*
302+
a standalone first name (``de``, ``dos``, ``ibn``, ...). When a name **starts**
303+
with one of these, there is no first name -- the whole thing is a surname.
304+
305+
Example::
306+
307+
>>> from nameparser import HumanName
308+
>>> hn = HumanName("de Mesnil")
309+
>>> hn.first, hn.last
310+
('', 'de Mesnil')
311+
312+
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
314+
the first name and never be a first name). Ambiguous particles that *can* be
315+
given names (``van``, ``von``, ``della``, ``di``, ``del``, ...) are intentionally
316+
excluded; add them yourself if your data warrants it::
317+
318+
>>> from nameparser.config import CONSTANTS
319+
>>> CONSTANTS.non_first_name_prefixes.add('von') # doctest: +SKIP
320+
321+
To **disable** the feature entirely::
322+
323+
>>> CONSTANTS.non_first_name_prefixes.clear() # doctest: +SKIP
324+
298325
Parser Customization Examples
299326
-----------------------------
300327

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Release Log
77
to 1.2.1 first (which includes a one-version compatibility shim), load and
88
re-pickle under 1.2.1, then upgrade to 1.3.0.
99

10+
- 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)
1011
- 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)
1112
- Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes #111)
1213
- Add ``suffix_acronyms_ambiguous`` to ``Constants`` for acronym suffixes that also read as given-name nicknames (e.g. ``"JD"``, ``"Ed"``), used when disambiguating parenthesized/quoted content (#111)

nameparser/config/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from typing_extensions import Self
3737

3838
from nameparser.util import lc
39-
from nameparser.config.prefixes import PREFIXES
39+
from nameparser.config.prefixes import PREFIXES, NON_FIRST_NAME_PREFIXES
4040
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES
4141
from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS
4242
from nameparser.config.conjunctions import CONJUNCTIONS
@@ -264,6 +264,11 @@ class Constants:
264264
:py:attr:`conjunctions` wrapped with :py:class:`SetManager`.
265265
:param set first_name_prefixes:
266266
:py:attr:`~first_name_prefixes.FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
267+
:param set non_first_name_prefixes:
268+
:py:attr:`~prefixes.NON_FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
269+
The subset of prefixes that are never a first name, so a *leading* one
270+
marks the whole name as a surname. Must stay disjoint from
271+
``first_name_prefixes``.
267272
:type capitalization_exceptions: tuple or dict
268273
:param capitalization_exceptions:
269274
:py:attr:`~capitalization.CAPITALIZATION_EXCEPTIONS` wrapped with :py:class:`TupleManager`.
@@ -289,6 +294,7 @@ class Constants:
289294
first_name_titles: SetManager
290295
conjunctions: SetManager
291296
first_name_prefixes: SetManager
297+
non_first_name_prefixes: SetManager
292298
suffix_acronyms_ambiguous: SetManager
293299
capitalization_exceptions: TupleManager[str]
294300
regexes: RegexTupleManager
@@ -463,6 +469,7 @@ def __init__(self,
463469
first_name_titles: Iterable[str] = FIRST_NAME_TITLES,
464470
conjunctions: Iterable[str] = CONJUNCTIONS,
465471
first_name_prefixes: Iterable[str] = FIRST_NAME_PREFIXES,
472+
non_first_name_prefixes: Iterable[str] = NON_FIRST_NAME_PREFIXES,
466473
capitalization_exceptions: TupleManager[str] | Iterable[tuple[str, str]] = CAPITALIZATION_EXCEPTIONS,
467474
regexes: RegexTupleManager | TupleManager[re.Pattern[str]] | Iterable[tuple[str, re.Pattern[str]]] = REGEXES,
468475
patronymic_name_order: bool = False,
@@ -478,6 +485,7 @@ def __init__(self,
478485
self.first_name_titles = SetManager(first_name_titles)
479486
self.conjunctions = SetManager(conjunctions)
480487
self.first_name_prefixes = SetManager(first_name_prefixes)
488+
self.non_first_name_prefixes = SetManager(non_first_name_prefixes)
481489
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
482490
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
483491
self.regexes = RegexTupleManager(regexes)

nameparser/config/prefixes.py

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1+
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES
2+
3+
#: The sub-set of :py:data:`PREFIXES` that are *never* a standalone first name.
4+
#: A name that *starts* with one of these has no first name -- the whole thing
5+
#: is a surname (e.g. "de Mesnil" -> last name "de Mesnil"). Curated to exclude
6+
#: anything that can be a given name in some culture (`al`, `van`, `von`,
7+
#: `della`, `di`, `del`, `da`, `vander`, ...) and anything that is also a first
8+
#: name prefix (`abu`). When unsure, leave a word out: a missing member just
9+
#: means that name is not auto-fixed, whereas a wrong member misparses a real
10+
#: person. Must stay a subset of :py:data:`PREFIXES` and disjoint from
11+
#: :py:data:`~nameparser.config.first_name_prefixes.FIRST_NAME_PREFIXES`.
12+
NON_FIRST_NAME_PREFIXES = set([
13+
"'t",
14+
'af',
15+
'auf',
16+
'av',
17+
'bint',
18+
'de',
19+
"de'",
20+
'degli',
21+
'dei',
22+
'delle',
23+
'delli',
24+
'dello',
25+
'dem',
26+
'der',
27+
'dos',
28+
'het',
29+
'ibn',
30+
'op',
31+
'ter',
32+
'vd',
33+
'vom',
34+
'zu',
35+
])
36+
137
#: Name pieces that appear before a last name. Prefixes join to the piece
238
#: that follows them to make one new piece. They can be chained together, e.g
339
#: "von der" and "de la". Because they only appear in middle or last names,
@@ -7,64 +43,55 @@
743
#: appear after a prefixes. So in "pennie von bergen wessels MD", "von" will
844
#: join with all following name pieces until the suffix "MD", resulting in the
945
#: correct parsing of the last name "von bergen wessels".
10-
PREFIXES = set([
11-
"'t",
46+
#:
47+
#: Defined as a static union so every :py:data:`NON_FIRST_NAME_PREFIXES` member
48+
#: is guaranteed to also be a prefix (and still join forward), with no drift --
49+
#: mirroring ``TITLES = FIRST_NAME_TITLES | {...}`` in
50+
#: :py:mod:`nameparser.config.titles`.
51+
PREFIXES = NON_FIRST_NAME_PREFIXES | set([
1252
'aan',
1353
'aen',
1454
'abu',
15-
'af',
1655
'al',
17-
'auf',
18-
'av',
1956
'bar',
2057
'bat',
2158
'bin',
22-
'bint',
2359
'bon',
2460
'da',
2561
'dal',
26-
'de',
27-
"de'",
28-
'degli',
29-
'dei',
3062
'del',
3163
'dela',
3264
'della',
33-
'delle',
34-
'delli',
35-
'dello',
36-
'dem',
3765
'den',
38-
'der',
3966
'di',
4067
'dí',
4168
'do',
42-
'dos',
4369
'du',
4470
'freiherr',
4571
'freiherrin',
4672
'heer',
47-
'het',
48-
'ibn',
4973
'la',
5074
'le',
5175
'mac',
5276
'mc',
53-
'op',
5477
'san',
5578
'santa',
5679
'st',
5780
'ste',
5881
'te',
59-
'ter',
6082
'tho',
6183
'thoe',
6284
'van',
6385
'vande',
6486
'vander',
65-
'vd',
6687
'vel',
67-
'vom',
6888
'von',
69-
'zu',
7089
])
90+
91+
# Guard the two invariants the docstring above promises, so a future edit that
92+
# breaks them fails at import time instead of silently drifting until a test
93+
# happens to catch it.
94+
assert NON_FIRST_NAME_PREFIXES <= PREFIXES, \
95+
"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"

nameparser/parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,11 @@ def is_first_name_prefix(self, piece: str) -> bool:
599599
"""Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.first_name_prefixes`."""
600600
return lc(piece) in self.C.first_name_prefixes
601601

602+
def is_non_first_name_prefix(self, piece: str) -> bool:
603+
"""Lowercased, leading/trailing-periods-stripped version of piece is in
604+
:py:attr:`~nameparser.config.Constants.non_first_name_prefixes`."""
605+
return lc(piece) in self.C.non_first_name_prefixes
606+
602607
def _join_first_name_prefix(self, pieces: list[str], reserve_last: bool) -> list[str]:
603608
"""Join a first-name prefix to its following piece.
604609
@@ -821,6 +826,21 @@ def handle_turkic_patronymic_name_order(self) -> None:
821826
self.first_list,
822827
)
823828

829+
def handle_non_first_name_prefix(self) -> None:
830+
"""
831+
A leading prefix that is never a first name means the whole name is a
832+
surname -- fold first (and any middle) into last. Keys on the parsed
833+
first name, so a non-leading particle ("Jean de Mesnil") is untouched
834+
and title/suffix are preserved. The middle_list/last_list guard leaves a
835+
degenerate bare "de" as first="de" rather than inventing a surname.
836+
"""
837+
if (len(self.first_list) == 1
838+
and self.is_non_first_name_prefix(self.first_list[0])
839+
and (self.middle_list or self.last_list)):
840+
self.last_list = self.first_list + self.middle_list + self.last_list
841+
self.first_list = []
842+
self.middle_list = []
843+
824844
def handle_middle_name_as_last(self) -> None:
825845
"""
826846
When middle_name_as_last is enabled, fold middle_list into last_list
@@ -837,6 +857,7 @@ def post_process(self) -> None:
837857
and :py:func:`handle_capitalization`.
838858
"""
839859
self.handle_firstnames()
860+
self.handle_non_first_name_prefix()
840861
if self.C.patronymic_name_order:
841862
self.handle_east_slavic_patronymic_name_order()
842863
self.handle_turkic_patronymic_name_order()

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"first_name_titles",
3636
"conjunctions",
3737
"first_name_prefixes",
38+
"non_first_name_prefixes",
3839
"capitalization_exceptions",
3940
"regexes",
4041
"nickname_delimiters",

0 commit comments

Comments
 (0)