From d6da248286a773ec7cac61666b47219717c36d00 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Fri, 3 Jul 2026 23:59:34 -0700 Subject: [PATCH 1/6] Add NON_FIRST_NAME_PREFIXES as a static subset of PREFIXES (#121) --- nameparser/config/prefixes.py | 63 ++++++++++++++++++++++------------- tests/test_prefixes.py | 21 ++++++++++++ 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/nameparser/config/prefixes.py b/nameparser/config/prefixes.py index ee1c0b4..7c4b188 100644 --- a/nameparser/config/prefixes.py +++ b/nameparser/config/prefixes.py @@ -1,3 +1,37 @@ +#: 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, @@ -7,64 +41,47 @@ #: 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', ]) diff --git a/tests/test_prefixes.py b/tests/test_prefixes.py index 8172ba4..045819e 100644 --- a/tests/test_prefixes.py +++ b/tests/test_prefixes.py @@ -1,6 +1,8 @@ import pytest from nameparser import HumanName +from nameparser.config.prefixes import PREFIXES, NON_FIRST_NAME_PREFIXES +from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES from tests.base import HumanNameTestBase @@ -163,6 +165,25 @@ def test_comma_three_conjunctions(self) -> None: self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) + def test_non_first_name_prefixes_subset_of_prefixes(self) -> None: + # Every non-first-name prefix must still be a prefix so it joins forward. + self.assertTrue(NON_FIRST_NAME_PREFIXES <= PREFIXES) + + def test_non_first_name_prefixes_disjoint_from_first_name_prefixes(self) -> None: + # A word cannot be both "joins to the first name" and "never a first + # name" (e.g. 'abu' is a first_name_prefix, so it is excluded here). + self.assertEqual(NON_FIRST_NAME_PREFIXES & FIRST_NAME_PREFIXES, set()) + + def test_non_first_name_prefixes_expected_members(self) -> None: + # 'abu' is in PREFIXES but excluded (it is a first_name_prefix); + # 'von'/'van'/'della'/'di'/'del' are excluded (they can be first names). + self.assertIn('de', NON_FIRST_NAME_PREFIXES) + self.assertIn('dos', NON_FIRST_NAME_PREFIXES) + self.assertNotIn('abu', NON_FIRST_NAME_PREFIXES) + self.assertNotIn('von', NON_FIRST_NAME_PREFIXES) + self.assertNotIn('van', NON_FIRST_NAME_PREFIXES) + self.assertNotIn('della', NON_FIRST_NAME_PREFIXES) + class LastNamePrefixSplitTestCase(HumanNameTestBase): From cb2576ffbb46faee0b0f4790d0094c7696825905 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 00:03:04 -0700 Subject: [PATCH 2/6] Expose Constants.non_first_name_prefixes SetManager (#121) --- nameparser/config/__init__.py | 10 +++++++++- tests/conftest.py | 1 + tests/test_prefixes.py | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index f6580b8..b4b8e74 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -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 @@ -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`. @@ -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 @@ -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, @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index e1a0034..d6fee22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ "first_name_titles", "conjunctions", "first_name_prefixes", + "non_first_name_prefixes", "capitalization_exceptions", "regexes", "nickname_delimiters", diff --git a/tests/test_prefixes.py b/tests/test_prefixes.py index 045819e..2a1469d 100644 --- a/tests/test_prefixes.py +++ b/tests/test_prefixes.py @@ -1,6 +1,7 @@ import pytest from nameparser import HumanName +from nameparser.config import CONSTANTS, Constants from nameparser.config.prefixes import PREFIXES, NON_FIRST_NAME_PREFIXES from nameparser.config.first_name_prefixes import FIRST_NAME_PREFIXES @@ -184,6 +185,22 @@ def test_non_first_name_prefixes_expected_members(self) -> None: self.assertNotIn('van', NON_FIRST_NAME_PREFIXES) self.assertNotIn('della', NON_FIRST_NAME_PREFIXES) + def test_constants_exposes_non_first_name_prefixes(self) -> None: + self.assertEqual(set(CONSTANTS.non_first_name_prefixes), NON_FIRST_NAME_PREFIXES) + + def test_non_first_name_prefixes_disjoint_from_titles(self) -> None: + # A member that is also a title is consumed as a title before the fold + # would run, making it inert (the st/ste footgun). Pin the invariant on + # the live default sets. + self.assertEqual( + set(CONSTANTS.non_first_name_prefixes) & set(CONSTANTS.titles), + set(), + ) + + def test_non_first_name_prefixes_constructor_arg(self) -> None: + c = Constants(non_first_name_prefixes={'zzz'}) + self.assertEqual(set(c.non_first_name_prefixes), {'zzz'}) + class LastNamePrefixSplitTestCase(HumanNameTestBase): From cc92d357ba30e8b80dff8bd32564c7f3dab665d9 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 00:06:47 -0700 Subject: [PATCH 3/6] Fold a leading non-first-name prefix into the surname (closes #121) --- nameparser/parser.py | 21 ++++++++++++ tests/test_prefixes.py | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/nameparser/parser.py b/nameparser/parser.py index 0124ffe..0a79e81 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -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. @@ -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 @@ -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() diff --git a/tests/test_prefixes.py b/tests/test_prefixes.py index 2a1469d..725908e 100644 --- a/tests/test_prefixes.py +++ b/tests/test_prefixes.py @@ -278,3 +278,76 @@ def test_case_insensitive_prefix_detection(self) -> None: hn = HumanName("VINCENT VAN GOGH") self.m(hn.last_prefixes, "VAN", hn) self.m(hn.last_base, "GOGH", hn) + + # --- targets: leading non-first-name prefix becomes the surname --- + + def test_leading_non_first_name_prefix_de(self) -> None: + hn = HumanName("de Mesnil") + self.m(hn.first, "", hn) + self.m(hn.middle, "", hn) + self.m(hn.last, "de Mesnil", hn) + + def test_leading_non_first_name_prefix_dos(self) -> None: + hn = HumanName("dos Santos") + self.m(hn.first, "", hn) + self.m(hn.last, "dos Santos", hn) + + def test_leading_non_first_name_prefix_chain(self) -> None: + hn = HumanName("de la Vega") + self.m(hn.first, "", hn) + self.m(hn.last, "de la Vega", hn) + + def test_leading_non_first_name_prefix_derived_props(self) -> None: + hn = HumanName("de Mesnil") + self.m(hn.last_prefixes, "de", hn) + self.m(hn.last_base, "Mesnil", hn) + + def test_non_first_name_prefix_with_custom_title(self) -> None: + # 'Gunny' is NOT a default title -> genuinely exercises the custom-title + # path. Title is consumed first (first_list == ['']), so the fold does + # not fire and the surname is already correct; asserts we don't corrupt + # the title case. + CONSTANTS.titles.add('gunny') + hn = HumanName("Gunny de Mesnil") + self.m(hn.title, "Gunny", hn) + self.m(hn.first, "", hn) + self.m(hn.last, "de Mesnil", hn) + + # --- safety: excluded / ambiguous particles are unchanged --- + + def test_leading_von_is_unchanged(self) -> None: + hn = HumanName("von Braun") + self.m(hn.first, "von", hn) + self.m(hn.last, "Braun", hn) + + def test_leading_van_is_unchanged(self) -> None: + hn = HumanName("Van Johnson") + self.m(hn.first, "Van", hn) + self.m(hn.last, "Johnson", hn) + + def test_leading_della_is_unchanged(self) -> None: + hn = HumanName("Della Reese") + self.m(hn.first, "Della", hn) + self.m(hn.last, "Reese", hn) + + def test_leading_di_is_unchanged(self) -> None: + hn = HumanName("Di Caprio") + self.m(hn.first, "Di", hn) + self.m(hn.last, "Caprio", hn) + + def test_leading_del_is_unchanged(self) -> None: + hn = HumanName("Del Toro") + self.m(hn.first, "Del", hn) + self.m(hn.last, "Toro", hn) + + def test_non_leading_prefix_is_unchanged(self) -> None: + hn = HumanName("Jean de Mesnil") + self.m(hn.first, "Jean", hn) + self.m(hn.last, "de Mesnil", hn) + + # --- guard: bare particle with nothing to attach to --- + + def test_bare_non_first_name_prefix_guard(self) -> None: + hn = HumanName("de") + self.m(hn.first, "de", hn) + self.m(hn.last, "", hn) From 64faab79f45c48b7fbdbdecfdec9e4ea7c3c85c7 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 00:13:02 -0700 Subject: [PATCH 4/6] Document non_first_name_prefixes and log 1.3.0 entry (#121) --- docs/customize.rst | 27 +++++++++++++++++++++++++++ docs/release_log.rst | 1 + 2 files changed, 28 insertions(+) diff --git a/docs/customize.rst b/docs/customize.rst index ad7b1e5..5ac1582 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -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 ----------------------------- diff --git a/docs/release_log.rst b/docs/release_log.rst index ac5f5be..8c675ba 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -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) From ceb605c764fbb41704bb33b13c09e2fb41b595c0 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 00:30:17 -0700 Subject: [PATCH 5/6] Assert NON_FIRST_NAME_PREFIXES invariants at import time (#121) Enforces the subset/disjointness invariants documented on the set so a future bad edit fails the import instead of silently drifting until a test happens to catch it. --- nameparser/config/prefixes.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nameparser/config/prefixes.py b/nameparser/config/prefixes.py index 7c4b188..cb81017 100644 --- a/nameparser/config/prefixes.py +++ b/nameparser/config/prefixes.py @@ -1,3 +1,5 @@ +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 @@ -85,3 +87,11 @@ 'vel', 'von', ]) + +# 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" From ec876c3d21e51a2f497036feff2c00e4c1d7f7da Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 4 Jul 2026 00:30:24 -0700 Subject: [PATCH 6/6] Cover opt-in-handler interactions and case for the surname fold (#121) Pins down behavior the review flagged as untested: case-insensitive matching, a suffix alongside the fold, and running with patronymic_name_order/middle_name_as_last enabled (confirming no double-processing against handle_non_first_name_prefix). --- tests/test_prefixes.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_prefixes.py b/tests/test_prefixes.py index 725908e..f17d596 100644 --- a/tests/test_prefixes.py +++ b/tests/test_prefixes.py @@ -351,3 +351,33 @@ def test_bare_non_first_name_prefix_guard(self) -> None: hn = HumanName("de") self.m(hn.first, "de", hn) self.m(hn.last, "", hn) + + # --- interactions with opt-in handlers that also run in post_process --- + + def test_leading_non_first_name_prefix_case_insensitive(self) -> None: + hn = HumanName("DE MESNIL") + self.m(hn.first, "", hn) + self.m(hn.last, "DE MESNIL", hn) + + def test_leading_non_first_name_prefix_with_suffix(self) -> None: + hn = HumanName("de Mesnil Jr.") + self.m(hn.first, "", hn) + self.m(hn.last, "de Mesnil", hn) + self.m(hn.suffix, "Jr.", hn) + + def test_leading_non_first_name_prefix_with_patronymic_name_order(self) -> None: + # The fold requires a single-token first_list; patronymic_name_order + # only matters once first_list has more than one piece, so the fold + # and this opt-in handler don't fight over the same input shape here. + constants = Constants(patronymic_name_order=True) + hn = HumanName("de Mesnil", constants=constants) + self.m(hn.first, "", hn) + self.m(hn.last, "de Mesnil", hn) + + def test_leading_non_first_name_prefix_with_middle_name_as_last(self) -> None: + # handle_non_first_name_prefix runs first and empties middle_list, so + # the later opt-in handle_middle_name_as_last has nothing left to do. + constants = Constants(middle_name_as_last=True) + hn = HumanName("de Mesnil Garcia", constants=constants) + self.m(hn.first, "", hn) + self.m(hn.last, "de Mesnil Garcia", hn)