diff --git a/docs/release_log.rst b/docs/release_log.rst index f28aac9..b831dcf 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -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 diff --git a/docs/usage.rst b/docs/usage.rst index 4334364..b06513f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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 + + >>> name = HumanName("J. Smith") + >>> name.first + 'J.' + >>> name.title + '' + Change the output string with string formatting ----------------------------------------------- diff --git a/nameparser/config/regexes.py b/nameparser/config/regexes.py index 3e62710..a15f873 100644 --- a/nameparser/config/regexes.py +++ b/nameparser/config/regexes.py @@ -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. diff --git a/nameparser/parser.py b/nameparser/parser.py index d9fd945..65934ac 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -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): @@ -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: @@ -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: @@ -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: diff --git a/tests/test_brute_force.py b/tests/test_brute_force.py index c46e81f..71a9533 100644 --- a/tests/test_brute_force.py +++ b/tests/test_brute_force.py @@ -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: diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 9fffb1b..27ae785 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -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) diff --git a/tests/test_titles.py b/tests/test_titles.py index 5bad578..0a95081 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -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) @@ -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)