From d4feffca2e1db1d164236a3b005d6736444c70ea Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 19:08:04 -0700 Subject: [PATCH 1/5] feat: add middle_name_as_last flag to Constants --- nameparser/config/__init__.py | 28 ++++++++++++++++++++++++++++ tests/test_middle_name_as_last.py | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/test_middle_name_as_last.py diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index e0374ce..39a6051 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -389,6 +389,32 @@ class Constants: """ + middle_name_as_last = False + """ + If set, folds middle names into the last name: ``middle_list`` is prepended + to ``last_list`` and ``middle_list`` is cleared, so ``.last`` becomes what + ``.surnames`` already was and ``.middle`` becomes empty. Useful for naming + systems with no middle-name concept, where everything after the given name + is lineage/family (e.g. Arabic patronymic chaining: given + father + + grandfather + family). + + The fold is uniform across both no-comma and comma ("Last, First Middle") + input, so the two written forms of a name converge on the same result. + + For per-instance control without a shared ``Constants``, pass a dedicated + instance: ``HumanName("...", constants=Constants(middle_name_as_last=True))``. + + .. doctest:: + + >>> from nameparser import HumanName + >>> from nameparser.config import Constants + >>> C = Constants(middle_name_as_last=True) + >>> hn = HumanName("Mohamad Ahmad Ali Hassan", constants=C) + >>> hn.first, hn.middle, hn.last + ('Mohamad', '', 'Ahmad Ali Hassan') + + """ + def __init__(self, prefixes: Iterable[str] = PREFIXES, suffix_acronyms: Iterable[str] = SUFFIX_ACRONYMS, @@ -401,6 +427,7 @@ def __init__(self, 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, + middle_name_as_last: bool = False, ) -> None: # These four descriptor assignments call _CachedUnionMember.__set__, which # calls _invalidate_pst() and establishes self._pst. They must come before @@ -423,6 +450,7 @@ def __init__(self, # needing to override parse_nicknames() itself. See issue #112. self.extra_nickname_delimiters = TupleManager() self.patronymic_name_order = patronymic_name_order + self.middle_name_as_last = middle_name_as_last def _invalidate_pst(self) -> None: self._pst = None diff --git a/tests/test_middle_name_as_last.py b/tests/test_middle_name_as_last.py new file mode 100644 index 0000000..1172f81 --- /dev/null +++ b/tests/test_middle_name_as_last.py @@ -0,0 +1,19 @@ +from nameparser.config import Constants +from tests.base import HumanNameTestBase + + +class MiddleNameAsLastFlagTests(HumanNameTestBase): + + def test_default_is_false(self) -> None: + C = Constants() + assert C.middle_name_as_last is False + + def test_can_set_true_via_constructor(self) -> None: + C = Constants(middle_name_as_last=True) + assert C.middle_name_as_last is True + + def test_does_not_affect_other_instance(self) -> None: + C1 = Constants(middle_name_as_last=True) + C2 = Constants() + assert C1.middle_name_as_last is True + assert C2.middle_name_as_last is False From 7a003118d0605f55287af3d847dfb012d7bb641b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 19:20:30 -0700 Subject: [PATCH 2/5] feat: implement middle_name_as_last fold (#133) --- nameparser/parser.py | 11 ++++++ tests/test_middle_name_as_last.py | 62 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/nameparser/parser.py b/nameparser/parser.py index 7b2e83f..d9fd945 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -753,6 +753,15 @@ def handle_patronymic_name_order(self) -> None: self.first_list, ) + def handle_middle_name_as_last(self) -> None: + """ + When middle_name_as_last is enabled, fold middle_list into last_list + (prepended, preserving order) and clear middle_list. No-op when + middle_list is already empty. + """ + self.last_list = self.middle_list + self.last_list + self.middle_list = [] + def post_process(self) -> None: """ This happens at the end of the :py:func:`parse_full_name` after @@ -762,6 +771,8 @@ def post_process(self) -> None: self.handle_firstnames() if self.C.patronymic_name_order: self.handle_patronymic_name_order() + if self.C.middle_name_as_last: + self.handle_middle_name_as_last() self.handle_capitalization() def fix_phd(self) -> None: diff --git a/tests/test_middle_name_as_last.py b/tests/test_middle_name_as_last.py index 1172f81..22ac4dd 100644 --- a/tests/test_middle_name_as_last.py +++ b/tests/test_middle_name_as_last.py @@ -1,3 +1,4 @@ +from nameparser import HumanName from nameparser.config import Constants from tests.base import HumanNameTestBase @@ -17,3 +18,64 @@ def test_does_not_affect_other_instance(self) -> None: C2 = Constants() assert C1.middle_name_as_last is True assert C2.middle_name_as_last is False + + +class MiddleNameAsLastFoldTests(HumanNameTestBase): + + def setup_method(self) -> None: + self.C = Constants(middle_name_as_last=True) + + def hn(self, name: str) -> HumanName: + return HumanName(name, constants=self.C) + + def test_fold_no_comma(self) -> None: + n = self.hn("Mohamad Ahmad Ali Hassan") + self.m(n.first, "Mohamad", n) + self.m(n.middle, "", n) + self.m(n.last, "Ahmad Ali Hassan", n) + + def test_fold_comma_converges(self) -> None: + no_comma = self.hn("Mohamad Ahmad Ali Hassan") + comma = self.hn("Hassan, Mohamad Ahmad Ali") + self.m(comma.first, no_comma.first, comma) + self.m(comma.last, no_comma.last, comma) + + def test_title_and_suffix_preserved(self) -> None: + n = self.hn("Dr. Mohamad Ahmad Hassan Jr") + self.m(n.title, "Dr.", n) + self.m(n.last, "Ahmad Hassan", n) + self.m(n.suffix, "Jr", n) + + def test_no_middle_is_noop(self) -> None: + n = self.hn("John Doe") + self.m(n.first, "John", n) + self.m(n.middle, "", n) + self.m(n.last, "Doe", n) + + def test_single_token_is_noop(self) -> None: + n = self.hn("Cher") + self.m(n.first, "Cher", n) + self.m(n.last, "", n) + + def test_given_names_and_surnames_track_fold(self) -> None: + n = self.hn("Mohamad Ahmad Ali Hassan") + self.m(n.given_names, n.first, n) + self.m(n.surnames, n.last, n) + + def test_last_prefixes_still_split_after_fold(self) -> None: + # Unfolded this is first="Miguel", middle="da Silva do Amaral", + # last="de Souza" (last_prefixes="de"). Folded, last_list becomes + # ["da","Silva","do","Amaral","de","Souza"]; _split_last() strips + # leading contiguous prefix words from the start, so only the + # leading "da" is stripped ("Silva" is not a prefix, so scanning + # stops there) — last_prefixes="da", not "de". + n = self.hn("Miguel da Silva do Amaral de Souza") + self.m(n.last_prefixes, "da", n) + + +class MiddleNameAsLastFlagOffTests(HumanNameTestBase): + + def test_default_constants_unaffected(self) -> None: + n = HumanName("Mohamad Ahmad Ali Hassan") + self.m(n.middle, "Ahmad Ali", n) + self.m(n.last, "Hassan", n) From 275360c9b10742fbf97cced966ca91659c5a5ff6 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 19:25:47 -0700 Subject: [PATCH 3/5] docs: document middle_name_as_last flag (#133) --- docs/customize.rst | 24 ++++++++++++++++++++++++ docs/release_log.rst | 1 + 2 files changed, 25 insertions(+) diff --git a/docs/customize.rst b/docs/customize.rst index 03c6dc1..570129c 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -88,6 +88,7 @@ Other editable attributes * :py:obj:`~nameparser.config.Constants.suffix_delimiter` - additional delimiter used to split suffix groups after comma-splitting, e.g. ``" - "`` for names like ``"Jane Smith, RN - CRNA"``. Defaults to ``None`` (disabled). * :py:obj:`~nameparser.config.Constants.initials_separator` - string placed between consecutive initials within the same name group (after the delimiter). Defaults to ``" "``, so ``"A. K."``; set to ``""`` for compact ``"A.K."``. * :py:obj:`~nameparser.config.Constants.patronymic_name_order` - If set, detects Russian formal-order names (``Surname GivenName Patronymic``) via a trailing East-Slavic patronymic suffix and rotates the parts to Western order (``first=GivenName``, ``middle=Patronymic``, ``last=Surname``). Opt-in; see subsection below. +* :py:obj:`~nameparser.config.Constants.middle_name_as_last` - If set, folds middle names into the last name (``.last`` becomes what ``.surnames`` already was, ``.middle`` becomes empty). Opt-in; see subsection below. Russian Formal Name Order @@ -117,6 +118,29 @@ patronymic-form surnames such as ``"David Michael Abramovich"``. Enable this flag only when your data is predominantly Russian formal-order names. +Suppressing Middle Names +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some naming systems have no middle-name concept — everything after the given +name is lineage or family (e.g. Arabic patronymic chaining: given + father + +grandfather + family). Enable ``middle_name_as_last`` to fold the middle name +into the last name instead of splitting them:: + + >>> from nameparser import HumanName + >>> from nameparser.config import Constants + >>> C = Constants(middle_name_as_last=True) + >>> hn = HumanName("Mohamad Ahmad Ali Hassan", constants=C) + >>> hn.first, hn.middle, hn.last + ('Mohamad', '', 'Ahmad Ali Hassan') + +The fold applies uniformly to comma input too, so both written forms of a name +converge on the same result:: + + >>> hn2 = HumanName("Hassan, Mohamad Ahmad Ali", constants=C) + >>> hn2.first, hn2.last + ('Mohamad', 'Ahmad Ali Hassan') + + Splitting last-name prefix particles ------------------------------------- diff --git a/docs/release_log.rst b/docs/release_log.rst index c43ee3b..f28aac9 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -29,6 +29,7 @@ Release Log ``middle="ahmed"``, ``last="salem"``). Disable via ``CONSTANTS.first_name_prefixes.clear()``. **Default-on: changes parsing output for names with these prefixes.** (#150) + - 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) * 1.2.1 - June 19, 2026 - 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`` - Add ``python -m nameparser "Name String"`` command-line helper that prints a parsed name From daa2394db5ab5140d28572be18cbdc8e0b647eaf Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 19:58:30 -0700 Subject: [PATCH 4/5] test: cover patronymic_name_order + middle_name_as_last interaction Both flags on: post_process() runs the patronymic hook before the fold, so rotation should settle first/middle/last before the fold collapses middle into last. Also covers the subtler case where a comma suppresses rotation (_had_comma guard) but the fold still absorbs the unrotated middle. Co-Authored-By: Claude Sonnet 5 --- tests/test_middle_name_as_last.py | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_middle_name_as_last.py b/tests/test_middle_name_as_last.py index 22ac4dd..595f749 100644 --- a/tests/test_middle_name_as_last.py +++ b/tests/test_middle_name_as_last.py @@ -79,3 +79,35 @@ def test_default_constants_unaffected(self) -> None: n = HumanName("Mohamad Ahmad Ali Hassan") self.m(n.middle, "Ahmad Ali", n) self.m(n.last, "Hassan", n) + + +class MiddleNameAsLastWithPatronymicOrderTests(HumanNameTestBase): + """Both localization flags on: patronymic reordering must settle + first/middle/last before the fold collapses middle into last, per the + design's stated ordering rationale (post_process() runs the patronymic + hook before the middle_name_as_last hook).""" + + def setup_method(self) -> None: + self.C = Constants(middle_name_as_last=True, patronymic_name_order=True) + + def hn(self, name: str) -> HumanName: + return HumanName(name, constants=self.C) + + def test_rotate_then_fold_no_comma(self) -> None: + # patronymic_name_order rotates "Ivanov Petr Sergeyevich" to + # first=Petr, middle=Sergeyevich, last=Ivanov; the fold then + # collapses that settled middle into last. + n = self.hn("Ivanov Petr Sergeyevich") + self.m(n.first, "Petr", n) + self.m(n.middle, "", n) + self.m(n.last, "Sergeyevich Ivanov", n) + + def test_fold_applies_even_when_comma_suppresses_rotation(self) -> None: + # A comma suppresses patronymic_name_order's rotation (_had_comma + # guard), so middle stays "Sergeyevich" unrotated going into the + # fold. The fold still absorbs it into last, producing the same + # first/last as the no-comma case above via a different mechanism. + n = self.hn("Ivanov, Petr Sergeyevich") + self.m(n.first, "Petr", n) + self.m(n.middle, "", n) + self.m(n.last, "Sergeyevich Ivanov", n) From 8d5c17f3a4b9bf51d615f5fc31e525fda097d99d Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:00:33 -0700 Subject: [PATCH 5/5] test: cover nickname, comma+suffix, and single-token middle in fold Addresses remaining review suggestions: nickname stripping happens in pre_process() before the fold and should compose correctly; the comma-delimited suffix form exercises a different code path than the no-comma title/suffix case; single-token names should leave middle empty too, not just last, for consistency with the other no-op test. Co-Authored-By: Claude Sonnet 5 --- tests/test_middle_name_as_last.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_middle_name_as_last.py b/tests/test_middle_name_as_last.py index 595f749..bfec02b 100644 --- a/tests/test_middle_name_as_last.py +++ b/tests/test_middle_name_as_last.py @@ -46,6 +46,22 @@ def test_title_and_suffix_preserved(self) -> None: self.m(n.last, "Ahmad Hassan", n) self.m(n.suffix, "Jr", n) + def test_suffix_preserved_comma_format(self) -> None: + # Comma-delimited suffix takes a different code path than the + # title/suffix no-comma case above; the fold must still apply. + n = self.hn("Hassan, Mohamad Ahmad Ali, Jr.") + self.m(n.first, "Mohamad", n) + self.m(n.middle, "", n) + self.m(n.last, "Ahmad Ali Hassan", n) + self.m(n.suffix, "Jr.", n) + + def test_nickname_preserved(self) -> None: + # Nicknames are stripped in pre_process(), before the fold runs. + n = self.hn('Mohamad "Mo" Ahmad Ali Hassan') + self.m(n.nickname, "Mo", n) + self.m(n.middle, "", n) + self.m(n.last, "Ahmad Ali Hassan", n) + def test_no_middle_is_noop(self) -> None: n = self.hn("John Doe") self.m(n.first, "John", n) @@ -55,6 +71,7 @@ def test_no_middle_is_noop(self) -> None: def test_single_token_is_noop(self) -> None: n = self.hn("Cher") self.m(n.first, "Cher", n) + self.m(n.middle, "", n) self.m(n.last, "", n) def test_given_names_and_surnames_track_fold(self) -> None: