Skip to content

Commit 03f2125

Browse files
authored
Merge pull request #198 from derek73/feat/turkic-patronymics
Add Turkic patronymic detection to patronymic_name_order
2 parents 01719f2 + 418c15b commit 03f2125

7 files changed

Lines changed: 442 additions & 17 deletions

File tree

docs/customize.rst

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Other editable attributes
8787
* :py:obj:`~nameparser.config.Constants.force_mixed_case_capitalization` - If set, forces the capitalization of mixed case strings when :py:meth:`~nameparser.parser.HumanName.capitalize` is called.
8888
* :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).
8989
* :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."``.
90-
* :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.
90+
* :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``). Also detects reversed-order Azerbaijani/Central-Asian Turkic patronymics (``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``). Opt-in; see subsections below.
9191
* :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.
9292

9393

@@ -118,6 +118,41 @@ patronymic-form surnames such as ``"David Michael Abramovich"``. Enable this
118118
flag only when your data is predominantly Russian formal-order names.
119119

120120

121+
Turkic Patronymics
122+
~~~~~~~~~~~~~~~~~~
123+
124+
Azerbaijani and Central-Asian formal names follow a different shape: a
125+
4-word ``[Given] [Father's given name] [Marker] [Family]``, where the
126+
marker is a standalone word (``oglu``/``oğlu`` "son of",
127+
``qizi``/``qızı`` "daughter of", and further variants — see below), not a
128+
bound suffix. The same ``patronymic_name_order`` flag also detects and
129+
rotates the reversed, no-comma form of this shape::
130+
131+
>>> from nameparser import HumanName
132+
>>> from nameparser.config import Constants
133+
>>> C = Constants(patronymic_name_order=True)
134+
>>> hn = HumanName("Aliyev Vusal Said oglu", constants=C)
135+
>>> hn.first, hn.middle, hn.last
136+
('Vusal', 'Said oglu', 'Aliyev')
137+
138+
Natural order (``"Vusal Said oglu Aliyev"``) and comma order
139+
(``"Aliyev, Vusal Said oglu"``) already parse correctly without this flag
140+
and are left unchanged.
141+
142+
Detection is scoped strictly to the 4-token shape (single-token first/last,
143+
exactly two middle tokens, last token a recognised marker) — matching the
144+
East-Slavic guard's token-count strictness above. Unlike that guard, there's
145+
no additional check on the given-name token, since Turkic markers are a
146+
small, closed set unlikely to coincide with an ordinary given name (whereas
147+
East-Slavic patronymic suffixes can coincide with real Western surnames).
148+
Recognised markers cover common transliterations and native orthographies:
149+
Latin ``oglu``, ``oğlu``, ``ogly``, ``ogli``, ``o'g'li`` (and its Uzbek
150+
modifier-apostrophe and right-single-quote variants), ``qizi``, ``qızı``,
151+
``kizi``, ``kyzy``, ``gyzy``, ``uly``, ``uulu``; and Cyrillic ``оглу``,
152+
``оглы``, ``оғлу``, ``ўғли``, ``угли``, ``кызы``, ``гызы``, ``қызы``,
153+
``қизи``, ``улы``, ``ұлы``, ``уулу``. Matching is case-insensitive.
154+
155+
121156
Suppressing Middle Names
122157
~~~~~~~~~~~~~~~~~~~~~~~~~
123158

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Release Log
2323
- Fix ``'apn aprn'`` split into separate ``suffix_acronyms`` entries so each is recognized independently (closes #155)
2424
- Add ``last_base``, ``last_prefixes`` (and ``_list`` variants) plus ``family`` / ``family_prefixes`` aliases for splitting last-name prefix particles (tussenvoegsels) from the core surname (#130, #132)
2525
- Add ``patronymic_name_order`` flag to ``Constants`` and ``HumanName`` for opt-in detection and reordering of Russian formal-order names (Surname GivenName Patronymic) (#85)
26+
- Add Turkic (Azerbaijani/Central-Asian) patronymic detection to ``patronymic_name_order``, rotating the reversed 4-token formal shape (``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``) into Western order (#185)
2627
- Add ``first_name_prefixes`` set to ``Constants``; bound Arabic given-name
2728
prefixes (``abdul``, ``abu``, etc.) now join forward to form a single first
2829
name (e.g. ``"abdul salam ahmed salem"`` → ``first="abdul salam"``,

nameparser/config/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,12 @@ class Constants:
370370
token in each of first, middle, and last; names with multi-part given names or
371371
multiple middle names are left unchanged.
372372
373+
Also detects reversed-order Azerbaijani/Central-Asian Turkic patronymics
374+
(``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``), a
375+
structurally different, standalone-marker-word patronymic family. Detection
376+
requires exactly one token in each of first and last, exactly two tokens in
377+
middle, and the last token a recognised Turkic marker.
378+
373379
Opt-in because a Western person whose surname happens to end in a patronymic
374380
suffix (e.g. ``"David Michael Abramovich"``) will be reordered incorrectly
375381
when the flag is on. Enable only when your data is predominantly Russian
@@ -386,6 +392,9 @@ class Constants:
386392
>>> hn = HumanName("Ivanov Ivan Ivanovich", constants=C)
387393
>>> hn.first, hn.middle, hn.last
388394
('Ivan', 'Ivanovich', 'Ivanov')
395+
>>> hn2 = HumanName("Aliyev Vusal Said oglu", constants=C)
396+
>>> hn2.first, hn2.middle, hn2.last
397+
('Vusal', 'Said oglu', 'Aliyev')
389398
390399
"""
391400

nameparser/config/regexes.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,22 @@
2222
("emoji",re_emoji),
2323
("phd", re.compile(r'\s(ph\.?\s+d\.?)', re.I)),
2424
("space_before_comma", re.compile(r'\s+,')),
25-
("patronymic", re.compile(
25+
("east_slavic_patronymic", re.compile(
2626
r'(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$',
2727
re.I,
2828
)),
29-
("patronymic_cyrillic", re.compile(
29+
("east_slavic_patronymic_cyrillic", re.compile(
3030
r'(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$',
31+
re.I,
32+
)),
33+
("turkic_patronymic_marker", re.compile(
34+
r"^(oglu|oğlu|ogly|ogli|o['’ʻ]g['’ʻ]li"
35+
r"|qizi|qızı|kizi|kyzy|gyzy|uly|uulu)$",
36+
re.I,
37+
)),
38+
("turkic_patronymic_marker_cyrillic", re.compile(
39+
r'^(оглу|оглы|оғлу|ўғли|угли|кызы|гызы|қызы|қизи|улы|ұлы|уулу)$',
40+
re.I,
3141
)),
3242
("period_abbreviation", re.compile(r'^[^\W\d_]{2,}\.$')),
3343
])

nameparser/parser.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ def is_an_initial(self, value: str) -> bool:
692692
"""
693693
return bool(self.C.regexes.initial.match(value))
694694

695-
def is_patronymic(self, piece: str) -> bool:
695+
def is_east_slavic_patronymic(self, piece: str) -> bool:
696696
"""
697697
Return True if ``piece`` ends with a recognised East-Slavic patronymic
698698
suffix, checked against both Latin-script and Cyrillic patterns in
@@ -702,8 +702,21 @@ def is_patronymic(self, piece: str) -> bool:
702702
by a separate pattern.
703703
"""
704704
return bool(
705-
self.C.regexes.patronymic.search(piece)
706-
or self.C.regexes.patronymic_cyrillic.search(piece)
705+
self.C.regexes.east_slavic_patronymic.search(piece)
706+
or self.C.regexes.east_slavic_patronymic_cyrillic.search(piece)
707+
)
708+
709+
def is_turkic_patronymic_marker(self, piece: str) -> bool:
710+
"""
711+
Return True if ``piece`` is exactly a recognised Turkic patronymic
712+
marker word (e.g. ``oglu``, ``qizi``, ``uly``), checked against both
713+
Latin-script and Cyrillic patterns in ``self.C.regexes``. Unlike
714+
East-Slavic patronymics, these are standalone marker words, not
715+
suffixes, so the match is whole-word rather than a suffix search.
716+
"""
717+
return bool(
718+
self.C.regexes.turkic_patronymic_marker.match(piece)
719+
or self.C.regexes.turkic_patronymic_marker_cyrillic.match(piece)
707720
)
708721

709722
# full_name parser
@@ -744,7 +757,7 @@ def pre_process(self) -> None:
744757
self.parse_nicknames()
745758
self.squash_emoji()
746759

747-
def handle_patronymic_name_order(self) -> None:
760+
def handle_east_slavic_patronymic_name_order(self) -> None:
748761
"""
749762
When patronymic_name_order is enabled, detect Russian formal order
750763
(Surname GivenName Patronymic) and rotate to Western order.
@@ -758,15 +771,36 @@ def handle_patronymic_name_order(self) -> None:
758771
and len(self.first_list) == 1
759772
and len(self.middle_list) == 1
760773
and len(self.last_list) == 1
761-
and self.is_patronymic(self.last_list[0])
762-
and not self.is_patronymic(self.middle_list[0])
774+
and self.is_east_slavic_patronymic(self.last_list[0])
775+
and not self.is_east_slavic_patronymic(self.middle_list[0])
763776
):
764777
self.first_list, self.middle_list, self.last_list = (
765778
self.middle_list,
766779
self.last_list,
767780
self.first_list,
768781
)
769782

783+
def handle_turkic_patronymic_name_order(self) -> None:
784+
"""
785+
When patronymic_name_order is enabled, detect the reversed Turkic
786+
formal order (Surname GivenName PatronymicRoot Marker) and rotate to
787+
Western order. Fires only for the strict 4-token, no-comma shape:
788+
single-token first/last and exactly two middle tokens, where the last
789+
token is a recognised Turkic patronymic marker.
790+
"""
791+
if (
792+
not self._had_comma
793+
and len(self.first_list) == 1
794+
and len(self.middle_list) == 2
795+
and len(self.last_list) == 1
796+
and self.is_turkic_patronymic_marker(self.last_list[0])
797+
):
798+
self.first_list, self.middle_list, self.last_list = (
799+
[self.middle_list[0]],
800+
[self.middle_list[1], self.last_list[0]],
801+
self.first_list,
802+
)
803+
770804
def handle_middle_name_as_last(self) -> None:
771805
"""
772806
When middle_name_as_last is enabled, fold middle_list into last_list
@@ -784,7 +818,8 @@ def post_process(self) -> None:
784818
"""
785819
self.handle_firstnames()
786820
if self.C.patronymic_name_order:
787-
self.handle_patronymic_name_order()
821+
self.handle_east_slavic_patronymic_name_order()
822+
self.handle_turkic_patronymic_name_order()
788823
if self.C.middle_name_as_last:
789824
self.handle_middle_name_as_last()
790825
self.handle_capitalization()

tests/test_patronymic_order.py renamed to tests/test_east_slavic_patronymic_order.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,47 @@
66
def test_latin_patronymic_matches() -> None:
77
# One common suffix and one irregular — the integration tests cover the rest.
88
C = Constants()
9-
assert C.regexes.patronymic.search("Ivanovich")
10-
assert C.regexes.patronymic.search("Ilyich")
9+
assert C.regexes.east_slavic_patronymic.search("Ivanovich")
10+
assert C.regexes.east_slavic_patronymic.search("Ilyich")
1111

1212

1313
def test_latin_patronymic_rejects_non_patronymic() -> None:
1414
# EMPTY_REGEX (the default for missing keys) matches everything,
1515
# so this test is red until the real pattern is in place.
1616
C = Constants()
17-
assert not C.regexes.patronymic.search("Smith")
17+
assert not C.regexes.east_slavic_patronymic.search("Smith")
1818

1919

2020
def test_latin_patronymic_end_anchored() -> None:
2121
# A surname ending in a patronymic suffix matches; the end-anchor does not
2222
# prevent this. The parser guard tests verify reordering is suppressed.
2323
C = Constants()
24-
assert C.regexes.patronymic.search("Abramovich")
24+
assert C.regexes.east_slavic_patronymic.search("Abramovich")
2525

2626

2727
def test_cyrillic_patronymic_matches() -> None:
2828
# One common suffix and one irregular.
2929
C = Constants()
30-
assert C.regexes.patronymic_cyrillic.search("Иванович")
31-
assert C.regexes.patronymic_cyrillic.search("ильич")
30+
assert C.regexes.east_slavic_patronymic_cyrillic.search("Иванович")
31+
assert C.regexes.east_slavic_patronymic_cyrillic.search("ильич")
32+
33+
34+
def test_cyrillic_patronymic_matches_capitalized_irregular_forms() -> None:
35+
# The irregular forms (Ильич, Кузьмич, ...) are short enough that the
36+
# capitalized first letter falls within the matched suffix itself, unlike
37+
# the common suffixes (-ович, -евна, ...) where only the surname root is
38+
# capitalized. Case-insensitivity is required for these to match.
39+
C = Constants()
40+
assert C.regexes.east_slavic_patronymic_cyrillic.search("Ильич")
41+
assert C.regexes.east_slavic_patronymic_cyrillic.search("Кузьмич")
42+
assert C.regexes.east_slavic_patronymic_cyrillic.search("Лукич")
43+
assert C.regexes.east_slavic_patronymic_cyrillic.search("Фомич")
44+
assert C.regexes.east_slavic_patronymic_cyrillic.search("Фокич")
3245

3346

3447
def test_cyrillic_patronymic_rejects_non_patronymic() -> None:
3548
C = Constants()
36-
assert not C.regexes.patronymic_cyrillic.search("Иванов")
49+
assert not C.regexes.east_slavic_patronymic_cyrillic.search("Иванов")
3750

3851

3952
class PatronymicNameOrderReorderTests(HumanNameTestBase):
@@ -77,6 +90,14 @@ def test_cyrillic(self) -> None:
7790
assert n.middle == "Иванович"
7891
assert n.last == "Иванов"
7992

93+
def test_cyrillic_capitalized_irregular_form(self) -> None:
94+
# "Ильич" is short enough that the capitalized first letter falls
95+
# within the irregular suffix itself; requires case-insensitive match.
96+
n = self.hn("Иванов Иван Ильич")
97+
assert n.first == "Иван"
98+
assert n.middle == "Ильич"
99+
assert n.last == "Иванов"
100+
80101
def test_title_preserved(self) -> None:
81102
n = self.hn("Dr. Ivanov Ivan Ivanovich")
82103
assert n.title == "Dr."

0 commit comments

Comments
 (0)