Skip to content

Commit cc92d35

Browse files
committed
Fold a leading non-first-name prefix into the surname (closes #121)
1 parent cb2576f commit cc92d35

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

nameparser/parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,11 @@ def is_first_name_prefix(self, piece: str) -> bool:
599599
"""Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.first_name_prefixes`."""
600600
return lc(piece) in self.C.first_name_prefixes
601601

602+
def is_non_first_name_prefix(self, piece: str) -> bool:
603+
"""Lowercased, leading/trailing-periods-stripped version of piece is in
604+
:py:attr:`~nameparser.config.Constants.non_first_name_prefixes`."""
605+
return lc(piece) in self.C.non_first_name_prefixes
606+
602607
def _join_first_name_prefix(self, pieces: list[str], reserve_last: bool) -> list[str]:
603608
"""Join a first-name prefix to its following piece.
604609
@@ -821,6 +826,21 @@ def handle_turkic_patronymic_name_order(self) -> None:
821826
self.first_list,
822827
)
823828

829+
def handle_non_first_name_prefix(self) -> None:
830+
"""
831+
A leading prefix that is never a first name means the whole name is a
832+
surname -- fold first (and any middle) into last. Keys on the parsed
833+
first name, so a non-leading particle ("Jean de Mesnil") is untouched
834+
and title/suffix are preserved. The middle_list/last_list guard leaves a
835+
degenerate bare "de" as first="de" rather than inventing a surname.
836+
"""
837+
if (len(self.first_list) == 1
838+
and self.is_non_first_name_prefix(self.first_list[0])
839+
and (self.middle_list or self.last_list)):
840+
self.last_list = self.first_list + self.middle_list + self.last_list
841+
self.first_list = []
842+
self.middle_list = []
843+
824844
def handle_middle_name_as_last(self) -> None:
825845
"""
826846
When middle_name_as_last is enabled, fold middle_list into last_list
@@ -837,6 +857,7 @@ def post_process(self) -> None:
837857
and :py:func:`handle_capitalization`.
838858
"""
839859
self.handle_firstnames()
860+
self.handle_non_first_name_prefix()
840861
if self.C.patronymic_name_order:
841862
self.handle_east_slavic_patronymic_name_order()
842863
self.handle_turkic_patronymic_name_order()

tests/test_prefixes.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,76 @@ def test_case_insensitive_prefix_detection(self) -> None:
278278
hn = HumanName("VINCENT VAN GOGH")
279279
self.m(hn.last_prefixes, "VAN", hn)
280280
self.m(hn.last_base, "GOGH", hn)
281+
282+
# --- targets: leading non-first-name prefix becomes the surname ---
283+
284+
def test_leading_non_first_name_prefix_de(self) -> None:
285+
hn = HumanName("de Mesnil")
286+
self.m(hn.first, "", hn)
287+
self.m(hn.middle, "", hn)
288+
self.m(hn.last, "de Mesnil", hn)
289+
290+
def test_leading_non_first_name_prefix_dos(self) -> None:
291+
hn = HumanName("dos Santos")
292+
self.m(hn.first, "", hn)
293+
self.m(hn.last, "dos Santos", hn)
294+
295+
def test_leading_non_first_name_prefix_chain(self) -> None:
296+
hn = HumanName("de la Vega")
297+
self.m(hn.first, "", hn)
298+
self.m(hn.last, "de la Vega", hn)
299+
300+
def test_leading_non_first_name_prefix_derived_props(self) -> None:
301+
hn = HumanName("de Mesnil")
302+
self.m(hn.last_prefixes, "de", hn)
303+
self.m(hn.last_base, "Mesnil", hn)
304+
305+
def test_non_first_name_prefix_with_custom_title(self) -> None:
306+
# 'Gunny' is NOT a default title -> genuinely exercises the custom-title
307+
# path. Title is consumed first (first_list == ['']), so the fold does
308+
# not fire and the surname is already correct; asserts we don't corrupt
309+
# the title case.
310+
CONSTANTS.titles.add('gunny')
311+
hn = HumanName("Gunny de Mesnil")
312+
self.m(hn.title, "Gunny", hn)
313+
self.m(hn.first, "", hn)
314+
self.m(hn.last, "de Mesnil", hn)
315+
316+
# --- safety: excluded / ambiguous particles are unchanged ---
317+
318+
def test_leading_von_is_unchanged(self) -> None:
319+
hn = HumanName("von Braun")
320+
self.m(hn.first, "von", hn)
321+
self.m(hn.last, "Braun", hn)
322+
323+
def test_leading_van_is_unchanged(self) -> None:
324+
hn = HumanName("Van Johnson")
325+
self.m(hn.first, "Van", hn)
326+
self.m(hn.last, "Johnson", hn)
327+
328+
def test_leading_della_is_unchanged(self) -> None:
329+
hn = HumanName("Della Reese")
330+
self.m(hn.first, "Della", hn)
331+
self.m(hn.last, "Reese", hn)
332+
333+
def test_leading_di_is_unchanged(self) -> None:
334+
hn = HumanName("Di Caprio")
335+
self.m(hn.first, "Di", hn)
336+
self.m(hn.last, "Caprio", hn)
337+
338+
def test_leading_del_is_unchanged(self) -> None:
339+
hn = HumanName("Del Toro")
340+
self.m(hn.first, "Del", hn)
341+
self.m(hn.last, "Toro", hn)
342+
343+
def test_non_leading_prefix_is_unchanged(self) -> None:
344+
hn = HumanName("Jean de Mesnil")
345+
self.m(hn.first, "Jean", hn)
346+
self.m(hn.last, "de Mesnil", hn)
347+
348+
# --- guard: bare particle with nothing to attach to ---
349+
350+
def test_bare_non_first_name_prefix_guard(self) -> None:
351+
hn = HumanName("de")
352+
self.m(hn.first, "de", hn)
353+
self.m(hn.last, "", hn)

0 commit comments

Comments
 (0)