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
27 changes: 27 additions & 0 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,33 @@ You can also pass a custom set per ``Constants`` instance::
>>> hn2.first, hn2.last
('abu bakr', 'al saud')

Non-First-Name Prefixes
-----------------------

``CONSTANTS.non_first_name_prefixes`` is the subset of prefixes that are *never*
a standalone first name (``de``, ``dos``, ``ibn``, ...). When a name **starts**
with one of these, there is no first name -- the whole thing is a surname.

Example::

>>> from nameparser import HumanName
>>> hn = HumanName("de Mesnil")
>>> hn.first, hn.last
('', 'de Mesnil')

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

>>> from nameparser.config import CONSTANTS
>>> CONSTANTS.non_first_name_prefixes.add('von') # doctest: +SKIP

To **disable** the feature entirely::

>>> CONSTANTS.non_first_name_prefixes.clear() # doctest: +SKIP

Parser Customization Examples
-----------------------------

Expand Down
1 change: 1 addition & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Release Log
to 1.2.1 first (which includes a one-version compatibility shim), load and
re-pickle under 1.2.1, then upgrade to 1.3.0.

- 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)
- 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)
- Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes #111)
- 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)
Expand Down
10 changes: 9 additions & 1 deletion nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from typing_extensions import Self

from nameparser.util import lc
from nameparser.config.prefixes import PREFIXES
from nameparser.config.prefixes import PREFIXES, NON_FIRST_NAME_PREFIXES
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES
from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS
from nameparser.config.conjunctions import CONJUNCTIONS
Expand Down Expand Up @@ -264,6 +264,11 @@ class Constants:
:py:attr:`conjunctions` wrapped with :py:class:`SetManager`.
:param set first_name_prefixes:
:py:attr:`~first_name_prefixes.FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
:param set non_first_name_prefixes:
:py:attr:`~prefixes.NON_FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`.
The subset of prefixes that are never a first name, so a *leading* one
marks the whole name as a surname. Must stay disjoint from
``first_name_prefixes``.
:type capitalization_exceptions: tuple or dict
:param capitalization_exceptions:
:py:attr:`~capitalization.CAPITALIZATION_EXCEPTIONS` wrapped with :py:class:`TupleManager`.
Expand All @@ -289,6 +294,7 @@ class Constants:
first_name_titles: SetManager
conjunctions: SetManager
first_name_prefixes: SetManager
non_first_name_prefixes: SetManager
suffix_acronyms_ambiguous: SetManager
capitalization_exceptions: TupleManager[str]
regexes: RegexTupleManager
Expand Down Expand Up @@ -463,6 +469,7 @@ def __init__(self,
first_name_titles: Iterable[str] = FIRST_NAME_TITLES,
conjunctions: Iterable[str] = CONJUNCTIONS,
first_name_prefixes: Iterable[str] = FIRST_NAME_PREFIXES,
non_first_name_prefixes: Iterable[str] = NON_FIRST_NAME_PREFIXES,
capitalization_exceptions: TupleManager[str] | Iterable[tuple[str, str]] = CAPITALIZATION_EXCEPTIONS,
regexes: RegexTupleManager | TupleManager[re.Pattern[str]] | Iterable[tuple[str, re.Pattern[str]]] = REGEXES,
patronymic_name_order: bool = False,
Expand All @@ -478,6 +485,7 @@ def __init__(self,
self.first_name_titles = SetManager(first_name_titles)
self.conjunctions = SetManager(conjunctions)
self.first_name_prefixes = SetManager(first_name_prefixes)
self.non_first_name_prefixes = SetManager(non_first_name_prefixes)
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
self.regexes = RegexTupleManager(regexes)
Expand Down
73 changes: 50 additions & 23 deletions nameparser/config/prefixes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES

#: The sub-set of :py:data:`PREFIXES` that are *never* a standalone first name.
#: A name that *starts* with one of these has no first name -- the whole thing
#: is a surname (e.g. "de Mesnil" -> last name "de Mesnil"). Curated to exclude
#: anything that can be a given name in some culture (`al`, `van`, `von`,
#: `della`, `di`, `del`, `da`, `vander`, ...) and anything that is also a first
#: name prefix (`abu`). When unsure, leave a word out: a missing member just
#: means that name is not auto-fixed, whereas a wrong member misparses a real
#: person. Must stay a subset of :py:data:`PREFIXES` and disjoint from
#: :py:data:`~nameparser.config.first_name_prefixes.FIRST_NAME_PREFIXES`.
NON_FIRST_NAME_PREFIXES = set([
"'t",
'af',
'auf',
'av',
'bint',
'de',
"de'",
'degli',
'dei',
'delle',
'delli',
'dello',
'dem',
'der',
'dos',
'het',
'ibn',
'op',
'ter',
'vd',
'vom',
'zu',
])

#: Name pieces that appear before a last name. Prefixes join to the piece
#: that follows them to make one new piece. They can be chained together, e.g
#: "von der" and "de la". Because they only appear in middle or last names,
Expand All @@ -7,64 +43,55 @@
#: appear after a prefixes. So in "pennie von bergen wessels MD", "von" will
#: join with all following name pieces until the suffix "MD", resulting in the
#: correct parsing of the last name "von bergen wessels".
PREFIXES = set([
"'t",
#:
#: Defined as a static union so every :py:data:`NON_FIRST_NAME_PREFIXES` member
#: is guaranteed to also be a prefix (and still join forward), with no drift --
#: mirroring ``TITLES = FIRST_NAME_TITLES | {...}`` in
#: :py:mod:`nameparser.config.titles`.
PREFIXES = NON_FIRST_NAME_PREFIXES | set([
'aan',
'aen',
'abu',
'af',
'al',
'auf',
'av',
'bar',
'bat',
'bin',
'bint',
'bon',
'da',
'dal',
'de',
"de'",
'degli',
'dei',
'del',
'dela',
'della',
'delle',
'delli',
'dello',
'dem',
'den',
'der',
'di',
'dí',
'do',
'dos',
'du',
'freiherr',
'freiherrin',
'heer',
'het',
'ibn',
'la',
'le',
'mac',
'mc',
'op',
'san',
'santa',
'st',
'ste',
'te',
'ter',
'tho',
'thoe',
'van',
'vande',
'vander',
'vd',
'vel',
'vom',
'von',
'zu',
])

# Guard the two invariants the docstring above promises, so a future edit that
# breaks them fails at import time instead of silently drifting until a test
# happens to catch it.
assert NON_FIRST_NAME_PREFIXES <= PREFIXES, \
"NON_FIRST_NAME_PREFIXES must stay a subset of PREFIXES"
assert not (NON_FIRST_NAME_PREFIXES & FIRST_NAME_PREFIXES), \
"NON_FIRST_NAME_PREFIXES must stay disjoint from FIRST_NAME_PREFIXES"
21 changes: 21 additions & 0 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,11 @@ def is_first_name_prefix(self, piece: str) -> bool:
"""Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.first_name_prefixes`."""
return lc(piece) in self.C.first_name_prefixes

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

def _join_first_name_prefix(self, pieces: list[str], reserve_last: bool) -> list[str]:
"""Join a first-name prefix to its following piece.

Expand Down Expand Up @@ -821,6 +826,21 @@ def handle_turkic_patronymic_name_order(self) -> None:
self.first_list,
)

def handle_non_first_name_prefix(self) -> None:
"""
A leading prefix that is never a first name means the whole name is a
surname -- fold first (and any middle) into last. Keys on the parsed
first name, so a non-leading particle ("Jean de Mesnil") is untouched
and title/suffix are preserved. The middle_list/last_list guard leaves a
degenerate bare "de" as first="de" rather than inventing a surname.
"""
if (len(self.first_list) == 1
and self.is_non_first_name_prefix(self.first_list[0])
and (self.middle_list or self.last_list)):
self.last_list = self.first_list + self.middle_list + self.last_list
self.first_list = []
self.middle_list = []

def handle_middle_name_as_last(self) -> None:
"""
When middle_name_as_last is enabled, fold middle_list into last_list
Expand All @@ -837,6 +857,7 @@ def post_process(self) -> None:
and :py:func:`handle_capitalization`.
"""
self.handle_firstnames()
self.handle_non_first_name_prefix()
if self.C.patronymic_name_order:
self.handle_east_slavic_patronymic_name_order()
self.handle_turkic_patronymic_name_order()
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"first_name_titles",
"conjunctions",
"first_name_prefixes",
"non_first_name_prefixes",
"capitalization_exceptions",
"regexes",
"nickname_delimiters",
Expand Down
Loading