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
1 change: 1 addition & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Release Log
``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)
- 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)
* 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
30 changes: 30 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,36 @@ reading in that ambiguous context:
nickname: 'JD'
]>

Leading Period-Abbreviation Titles
-----------------------------------

An unrecognized, multi-letter word ending in a period, found anywhere in the
leading title run (i.e. before the first name is set), is treated as a title
-- this covers military ranks and other abbreviations that aren't in the
built-in titles list, including chained abbreviations like
``"Foo. Xyz. John Smith"``. Single-letter initials (``"J."``) and
internal-period abbreviations (``"E.T."``) are not affected, and the same
word appearing after the first name is left as a middle name.

.. doctest:: leading_period_titles
:options: +NORMALIZE_WHITESPACE

>>> name = HumanName("Major. Dona Smith")
>>> name
<HumanName : [
title: 'Major.'
first: 'Dona'
middle: ''
last: 'Smith'
suffix: ''
nickname: ''
]>
>>> name = HumanName("J. Smith")
>>> name.first
'J.'
>>> name.title
''

Change the output string with string formatting
-----------------------------------------------

Expand Down
1 change: 1 addition & 0 deletions nameparser/config/regexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
r'(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$',
re.U,
)),
("period_abbreviation", re.compile(r'^[^\W\d_]{2,}\.$', re.U)),
])
"""
All regular expressions used by the parser are precompiled and stored in the config.
Expand Down
20 changes: 17 additions & 3 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,20 @@ def is_title(self, value: str) -> bool:
"""Is in the :py:data:`~nameparser.config.titles.TITLES` set."""
return lc(value) in self.C.titles

def is_leading_title(self, piece: str) -> bool:
"""
True if ``piece`` is a known title, or an unrecognized multi-letter
word ending in a single trailing period (e.g. ``"Major."``). The
``{2,}`` in the ``period_abbreviation`` regex, not a separate
``is_an_initial()`` check, is what excludes single-letter initials
like ``"J."``. Only meaningful for pieces in the title position
(before the first name is set) — a period-abbreviation appearing
later in the name is left as a middle name. Does not mutate
``C.titles``, so the periodless form (``"Major"``) is never affected
in later parses.
"""
return self.is_title(piece) or bool(self.C.regexes.period_abbreviation.match(piece))

def is_conjunction(self, piece: str) -> bool:
"""Is in the conjunctions set and not :py:func:`is_an_initial()`."""
if isinstance(piece, list):
Expand Down Expand Up @@ -930,7 +944,7 @@ def parse_full_name(self) -> None:
# title must have a next piece, unless it's just a title
if not self.first \
and (nxt or p_len == 1) \
and self.is_title(piece):
and self.is_leading_title(piece):
self.title_list.append(piece)
continue
if not self.first:
Expand Down Expand Up @@ -981,7 +995,7 @@ def parse_full_name(self) -> None:

if not self.first \
and (nxt or len(pieces) == 1) \
and self.is_title(piece):
and self.is_leading_title(piece):
self.title_list.append(piece)
continue
if not self.first:
Expand Down Expand Up @@ -1022,7 +1036,7 @@ def parse_full_name(self) -> None:

if not self.first \
and (nxt or len(post_comma_pieces) == 1) \
and self.is_title(piece):
and self.is_leading_title(piece):
self.title_list.append(piece)
continue
if not self.first:
Expand Down
20 changes: 14 additions & 6 deletions tests/test_brute_force.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,23 +100,31 @@ def test15(self) -> None:
self.m(hn.suffix, "III", hn)

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

def test17(self) -> None:
# Same period-abbreviation-as-title behavior as test16 (see #109).
hn = HumanName("Doe, John. A. Kenneth, Jr.")
self.m(hn.first, "John.", hn)
self.m(hn.title, "John.", hn)
self.m(hn.first, "A.", hn)
self.m(hn.last, "Doe", hn)
self.m(hn.middle, "A. Kenneth", hn)
self.m(hn.middle, "Kenneth", hn)
self.m(hn.suffix, "Jr.", hn)

def test18(self) -> None:
# Same period-abbreviation-as-title behavior as test16 (see #109).
hn = HumanName("Doe, John. A. Kenneth III")
self.m(hn.first, "John.", hn)
self.m(hn.title, "John.", hn)
self.m(hn.first, "A.", hn)
self.m(hn.last, "Doe", hn)
self.m(hn.middle, "A. Kenneth", hn)
self.m(hn.middle, "Kenneth", hn)
self.m(hn.suffix, "III", hn)

def test19(self) -> None:
Expand Down
9 changes: 6 additions & 3 deletions tests/test_suffixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,13 @@ def test_suffix_in_parenthesis_mid_name(self) -> None:
def test_suffix_in_parenthesis_with_period(self) -> None:
# Same known limitation as above: "Ret." is mid-name (no comma), so
# it's outside the trailing run parse_full_name's suffix detection
# requires. It parses exactly as bare "Col. Ret. Smith" would.
# requires. It parses exactly as bare "Col. Ret. Smith" would: since
# "Ret." is an unrecognized period-abbreviation appearing before the
# first name is set, is_leading_title() treats it as a second title
# token (see #109), joining "Col." into a single title string.
hn = HumanName("Col. (Ret.) Smith")
self.m(hn.title, "Col.", hn)
self.m(hn.first, "Ret.", hn)
self.m(hn.title, "Col. Ret.", hn)
self.m(hn.first, "", hn)
self.m(hn.last, "Smith", hn)
self.m(hn.suffix, "", hn)
self.m(hn.nickname, "", hn)
Expand Down
86 changes: 86 additions & 0 deletions tests/test_titles.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ def test_last_name_is_also_title_with_comma(self) -> None:
self.m(hn.last, "King", hn)
self.m(hn.suffix, "Jr.", hn)

def test_leading_period_abbreviation_is_title(self) -> None:
hn = HumanName("Major. Dona Smith")
self.m(hn.title, "Major.", hn)
self.m(hn.first, "Dona", hn)
self.m(hn.last, "Smith", hn)

def test_last_name_is_also_title3(self) -> None:
hn = HumanName("John King")
self.m(hn.first, "John", hn)
Expand Down Expand Up @@ -272,3 +278,83 @@ def test_herr_title_with_first_name(self) -> None:
self.m(hn.title, "Herr", hn)
self.m(hn.first, "Klaus", hn)
self.m(hn.last, "Schmidt", hn)

def test_leading_period_abbreviation_suffix_comma(self) -> None:
hn = HumanName("Major. John Smith, Jr.")
self.m(hn.title, "Major.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)
self.m(hn.suffix, "Jr.", hn)

def test_leading_period_abbreviation_lastname_comma(self) -> None:
hn = HumanName("Smith, Major. John")
self.m(hn.title, "Major.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)

def test_leading_period_abbreviation_unknown_word(self) -> None:
hn = HumanName("Foo. John Smith")
self.m(hn.title, "Foo.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)

def test_leading_period_abbreviation_chained(self) -> None:
hn = HumanName("Foo. Xyz. John Smith")
self.m(hn.title, "Foo. Xyz.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)

def test_leading_single_letter_initial_excluded(self) -> None:
hn = HumanName("J. Smith")
self.m(hn.first, "J.", hn)
self.m(hn.last, "Smith", hn)
self.m(hn.title, "", hn)

def test_leading_internal_period_abbreviation_excluded(self) -> None:
hn = HumanName("E.T. Smith")
self.m(hn.first, "E.T.", hn)
self.m(hn.last, "Smith", hn)
self.m(hn.title, "", hn)

def test_period_abbreviation_after_first_name_stays_middle(self) -> None:
hn = HumanName("John Major. Smith")
self.m(hn.first, "John", hn)
self.m(hn.middle, "Major.", hn)
self.m(hn.last, "Smith", hn)
self.m(hn.title, "", hn)

def test_known_title_with_period_still_a_title(self) -> None:
hn = HumanName("Dr. John Smith")
self.m(hn.title, "Dr.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)

def test_middle_initial_with_period_unaffected(self) -> None:
hn = HumanName("John Q. Smith")
self.m(hn.first, "John", hn)
self.m(hn.middle, "Q.", hn)
self.m(hn.last, "Smith", hn)

def test_leading_period_abbreviation_excludes_digits(self) -> None:
hn = HumanName("No1. John Smith")
self.m(hn.title, "", hn)
self.m(hn.first, "No1.", hn)
self.m(hn.last, "Smith", hn)

def test_leading_period_abbreviation_excludes_apostrophe(self) -> None:
hn = HumanName("O'B. John Smith")
self.m(hn.title, "", hn)
self.m(hn.first, "O'B.", hn)
self.m(hn.last, "Smith", hn)

def test_leading_period_abbreviation_case_insensitive(self) -> None:
hn = HumanName("xyz. John Smith")
self.m(hn.title, "xyz.", hn)
self.m(hn.first, "John", hn)
self.m(hn.last, "Smith", hn)

def test_leading_period_abbreviation_with_nickname(self) -> None:
hn = HumanName("Xyz. (Bud) Smith")
self.m(hn.title, "Xyz.", hn)
self.m(hn.first, "Smith", hn)
self.m(hn.nickname, "Bud", hn)