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
24 changes: 24 additions & 0 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
-------------------------------------

Expand Down
1 change: 1 addition & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
130 changes: 130 additions & 0 deletions tests/test_middle_name_as_last.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from nameparser import HumanName
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


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_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)
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.middle, "", 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)


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)