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 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/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 new file mode 100644 index 0000000..bfec02b --- /dev/null +++ b/tests/test_middle_name_as_last.py @@ -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)