Skip to content

Commit 7728747

Browse files
authored
Merge pull request #196 from derek73/worktree-leading-period-title
Treat leading period-abbreviations as titles (#109)
2 parents 61f8c2a + 6bba2eb commit 7728747

7 files changed

Lines changed: 155 additions & 12 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Release Log
3030
``CONSTANTS.first_name_prefixes.clear()``. **Default-on: changes parsing
3131
output for names with these prefixes.** (#150)
3232
- 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)
33+
- Treat an unrecognized, multi-letter token ending in a period in the leading title run (before the first name is set), e.g. ``"Major."``, as a ``title`` instead of a ``first`` name; internal-period abbreviations (``"E.T."``) and single-letter initials (``"J."``) are unaffected. **Default-on: changes parsing of names with a leading unknown period-abbreviation** (closes #109)
3334
* 1.2.1 - June 19, 2026
3435
- 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``
3536
- Add ``python -m nameparser "Name String"`` command-line helper that prints a parsed name

docs/usage.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,36 @@ reading in that ambiguous context:
188188
nickname: 'JD'
189189
]>
190190

191+
Leading Period-Abbreviation Titles
192+
-----------------------------------
193+
194+
An unrecognized, multi-letter word ending in a period, found anywhere in the
195+
leading title run (i.e. before the first name is set), is treated as a title
196+
-- this covers military ranks and other abbreviations that aren't in the
197+
built-in titles list, including chained abbreviations like
198+
``"Foo. Xyz. John Smith"``. Single-letter initials (``"J."``) and
199+
internal-period abbreviations (``"E.T."``) are not affected, and the same
200+
word appearing after the first name is left as a middle name.
201+
202+
.. doctest:: leading_period_titles
203+
:options: +NORMALIZE_WHITESPACE
204+
205+
>>> name = HumanName("Major. Dona Smith")
206+
>>> name
207+
<HumanName : [
208+
title: 'Major.'
209+
first: 'Dona'
210+
middle: ''
211+
last: 'Smith'
212+
suffix: ''
213+
nickname: ''
214+
]>
215+
>>> name = HumanName("J. Smith")
216+
>>> name.first
217+
'J.'
218+
>>> name.title
219+
''
220+
191221
Change the output string with string formatting
192222
-----------------------------------------------
193223

nameparser/config/regexes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
r'(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$',
3232
re.U,
3333
)),
34+
("period_abbreviation", re.compile(r'^[^\W\d_]{2,}\.$', re.U)),
3435
])
3536
"""
3637
All regular expressions used by the parser are precompiled and stored in the config.

nameparser/parser.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,20 @@ def is_title(self, value: str) -> bool:
540540
"""Is in the :py:data:`~nameparser.config.titles.TITLES` set."""
541541
return lc(value) in self.C.titles
542542

543+
def is_leading_title(self, piece: str) -> bool:
544+
"""
545+
True if ``piece`` is a known title, or an unrecognized multi-letter
546+
word ending in a single trailing period (e.g. ``"Major."``). The
547+
``{2,}`` in the ``period_abbreviation`` regex, not a separate
548+
``is_an_initial()`` check, is what excludes single-letter initials
549+
like ``"J."``. Only meaningful for pieces in the title position
550+
(before the first name is set) — a period-abbreviation appearing
551+
later in the name is left as a middle name. Does not mutate
552+
``C.titles``, so the periodless form (``"Major"``) is never affected
553+
in later parses.
554+
"""
555+
return self.is_title(piece) or bool(self.C.regexes.period_abbreviation.match(piece))
556+
543557
def is_conjunction(self, piece: str) -> bool:
544558
"""Is in the conjunctions set and not :py:func:`is_an_initial()`."""
545559
if isinstance(piece, list):
@@ -930,7 +944,7 @@ def parse_full_name(self) -> None:
930944
# title must have a next piece, unless it's just a title
931945
if not self.first \
932946
and (nxt or p_len == 1) \
933-
and self.is_title(piece):
947+
and self.is_leading_title(piece):
934948
self.title_list.append(piece)
935949
continue
936950
if not self.first:
@@ -981,7 +995,7 @@ def parse_full_name(self) -> None:
981995

982996
if not self.first \
983997
and (nxt or len(pieces) == 1) \
984-
and self.is_title(piece):
998+
and self.is_leading_title(piece):
985999
self.title_list.append(piece)
9861000
continue
9871001
if not self.first:
@@ -1022,7 +1036,7 @@ def parse_full_name(self) -> None:
10221036

10231037
if not self.first \
10241038
and (nxt or len(post_comma_pieces) == 1) \
1025-
and self.is_title(piece):
1039+
and self.is_leading_title(piece):
10261040
self.title_list.append(piece)
10271041
continue
10281042
if not self.first:

tests/test_brute_force.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,23 +100,31 @@ def test15(self) -> None:
100100
self.m(hn.suffix, "III", hn)
101101

102102
def test16(self) -> None:
103+
# "John." is an unrecognized period-abbreviation in the leading
104+
# title position of the lastname-comma format, so is_leading_title()
105+
# now treats it as a title rather than a first name (see #109).
103106
hn = HumanName("Doe, John. A. Kenneth")
104-
self.m(hn.first, "John.", hn)
107+
self.m(hn.title, "John.", hn)
108+
self.m(hn.first, "A.", hn)
105109
self.m(hn.last, "Doe", hn)
106-
self.m(hn.middle, "A. Kenneth", hn)
110+
self.m(hn.middle, "Kenneth", hn)
107111

108112
def test17(self) -> None:
113+
# Same period-abbreviation-as-title behavior as test16 (see #109).
109114
hn = HumanName("Doe, John. A. Kenneth, Jr.")
110-
self.m(hn.first, "John.", hn)
115+
self.m(hn.title, "John.", hn)
116+
self.m(hn.first, "A.", hn)
111117
self.m(hn.last, "Doe", hn)
112-
self.m(hn.middle, "A. Kenneth", hn)
118+
self.m(hn.middle, "Kenneth", hn)
113119
self.m(hn.suffix, "Jr.", hn)
114120

115121
def test18(self) -> None:
122+
# Same period-abbreviation-as-title behavior as test16 (see #109).
116123
hn = HumanName("Doe, John. A. Kenneth III")
117-
self.m(hn.first, "John.", hn)
124+
self.m(hn.title, "John.", hn)
125+
self.m(hn.first, "A.", hn)
118126
self.m(hn.last, "Doe", hn)
119-
self.m(hn.middle, "A. Kenneth", hn)
127+
self.m(hn.middle, "Kenneth", hn)
120128
self.m(hn.suffix, "III", hn)
121129

122130
def test19(self) -> None:

tests/test_suffixes.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,13 @@ def test_suffix_in_parenthesis_mid_name(self) -> None:
310310
def test_suffix_in_parenthesis_with_period(self) -> None:
311311
# Same known limitation as above: "Ret." is mid-name (no comma), so
312312
# it's outside the trailing run parse_full_name's suffix detection
313-
# requires. It parses exactly as bare "Col. Ret. Smith" would.
313+
# requires. It parses exactly as bare "Col. Ret. Smith" would: since
314+
# "Ret." is an unrecognized period-abbreviation appearing before the
315+
# first name is set, is_leading_title() treats it as a second title
316+
# token (see #109), joining "Col." into a single title string.
314317
hn = HumanName("Col. (Ret.) Smith")
315-
self.m(hn.title, "Col.", hn)
316-
self.m(hn.first, "Ret.", hn)
318+
self.m(hn.title, "Col. Ret.", hn)
319+
self.m(hn.first, "", hn)
317320
self.m(hn.last, "Smith", hn)
318321
self.m(hn.suffix, "", hn)
319322
self.m(hn.nickname, "", hn)

tests/test_titles.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def test_last_name_is_also_title_with_comma(self) -> None:
2929
self.m(hn.last, "King", hn)
3030
self.m(hn.suffix, "Jr.", hn)
3131

32+
def test_leading_period_abbreviation_is_title(self) -> None:
33+
hn = HumanName("Major. Dona Smith")
34+
self.m(hn.title, "Major.", hn)
35+
self.m(hn.first, "Dona", hn)
36+
self.m(hn.last, "Smith", hn)
37+
3238
def test_last_name_is_also_title3(self) -> None:
3339
hn = HumanName("John King")
3440
self.m(hn.first, "John", hn)
@@ -272,3 +278,83 @@ def test_herr_title_with_first_name(self) -> None:
272278
self.m(hn.title, "Herr", hn)
273279
self.m(hn.first, "Klaus", hn)
274280
self.m(hn.last, "Schmidt", hn)
281+
282+
def test_leading_period_abbreviation_suffix_comma(self) -> None:
283+
hn = HumanName("Major. John Smith, Jr.")
284+
self.m(hn.title, "Major.", hn)
285+
self.m(hn.first, "John", hn)
286+
self.m(hn.last, "Smith", hn)
287+
self.m(hn.suffix, "Jr.", hn)
288+
289+
def test_leading_period_abbreviation_lastname_comma(self) -> None:
290+
hn = HumanName("Smith, Major. John")
291+
self.m(hn.title, "Major.", hn)
292+
self.m(hn.first, "John", hn)
293+
self.m(hn.last, "Smith", hn)
294+
295+
def test_leading_period_abbreviation_unknown_word(self) -> None:
296+
hn = HumanName("Foo. John Smith")
297+
self.m(hn.title, "Foo.", hn)
298+
self.m(hn.first, "John", hn)
299+
self.m(hn.last, "Smith", hn)
300+
301+
def test_leading_period_abbreviation_chained(self) -> None:
302+
hn = HumanName("Foo. Xyz. John Smith")
303+
self.m(hn.title, "Foo. Xyz.", hn)
304+
self.m(hn.first, "John", hn)
305+
self.m(hn.last, "Smith", hn)
306+
307+
def test_leading_single_letter_initial_excluded(self) -> None:
308+
hn = HumanName("J. Smith")
309+
self.m(hn.first, "J.", hn)
310+
self.m(hn.last, "Smith", hn)
311+
self.m(hn.title, "", hn)
312+
313+
def test_leading_internal_period_abbreviation_excluded(self) -> None:
314+
hn = HumanName("E.T. Smith")
315+
self.m(hn.first, "E.T.", hn)
316+
self.m(hn.last, "Smith", hn)
317+
self.m(hn.title, "", hn)
318+
319+
def test_period_abbreviation_after_first_name_stays_middle(self) -> None:
320+
hn = HumanName("John Major. Smith")
321+
self.m(hn.first, "John", hn)
322+
self.m(hn.middle, "Major.", hn)
323+
self.m(hn.last, "Smith", hn)
324+
self.m(hn.title, "", hn)
325+
326+
def test_known_title_with_period_still_a_title(self) -> None:
327+
hn = HumanName("Dr. John Smith")
328+
self.m(hn.title, "Dr.", hn)
329+
self.m(hn.first, "John", hn)
330+
self.m(hn.last, "Smith", hn)
331+
332+
def test_middle_initial_with_period_unaffected(self) -> None:
333+
hn = HumanName("John Q. Smith")
334+
self.m(hn.first, "John", hn)
335+
self.m(hn.middle, "Q.", hn)
336+
self.m(hn.last, "Smith", hn)
337+
338+
def test_leading_period_abbreviation_excludes_digits(self) -> None:
339+
hn = HumanName("No1. John Smith")
340+
self.m(hn.title, "", hn)
341+
self.m(hn.first, "No1.", hn)
342+
self.m(hn.last, "Smith", hn)
343+
344+
def test_leading_period_abbreviation_excludes_apostrophe(self) -> None:
345+
hn = HumanName("O'B. John Smith")
346+
self.m(hn.title, "", hn)
347+
self.m(hn.first, "O'B.", hn)
348+
self.m(hn.last, "Smith", hn)
349+
350+
def test_leading_period_abbreviation_case_insensitive(self) -> None:
351+
hn = HumanName("xyz. John Smith")
352+
self.m(hn.title, "xyz.", hn)
353+
self.m(hn.first, "John", hn)
354+
self.m(hn.last, "Smith", hn)
355+
356+
def test_leading_period_abbreviation_with_nickname(self) -> None:
357+
hn = HumanName("Xyz. (Bud) Smith")
358+
self.m(hn.title, "Xyz.", hn)
359+
self.m(hn.first, "Smith", hn)
360+
self.m(hn.nickname, "Bud", hn)

0 commit comments

Comments
 (0)