From d4f7dcd5ff1f6321ee2ab16826620e51b7daada8 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:27:43 -0700 Subject: [PATCH 1/6] feat: infer leading period-abbreviations as titles in no-comma path (#109) Add period_abbreviation regex and is_leading_title() helper. In the no-comma parse path, an unrecognized multi-letter token ending in a period before the first name is set (e.g. "Major.") is now parsed as title instead of first. Update test_suffix_in_parenthesis_with_period, which documented the old behavior as a known limitation, to match. --- nameparser/config/regexes.py | 1 + nameparser/parser.py | 13 ++++++++++++- tests/test_suffixes.py | 9 ++++++--- tests/test_titles.py | 6 ++++++ 4 files changed, 25 insertions(+), 4 deletions(-) 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..c8a4cc2 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -540,6 +540,17 @@ 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."``). 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 +941,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: 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..40e8b02 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) From 71d91a2b4ede00742ba3b569874c0459b3042bf3 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:32:01 -0700 Subject: [PATCH 2/6] feat: infer leading period-abbreviations as titles in suffix-comma path (#109) --- nameparser/parser.py | 2 +- tests/test_titles.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/nameparser/parser.py b/nameparser/parser.py index c8a4cc2..df7ee70 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -992,7 +992,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: diff --git a/tests/test_titles.py b/tests/test_titles.py index 40e8b02..3ad0d91 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -278,3 +278,10 @@ 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) From 1203f59be2dda87b69a858f1a20cebd6bc427db5 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:36:38 -0700 Subject: [PATCH 3/6] feat: infer leading period-abbreviations as titles in lastname-comma path (#109) Complete the leading-title wiring across all three parse_full_name() paths. Update test_brute_force test16-18, which documented the old behavior for "Doe, John. A. Kenneth..." (unrecognized "John." parsed as first name); it's now correctly recognized as a leading title, same as the no-comma and suffix-comma paths. --- nameparser/parser.py | 2 +- tests/test_brute_force.py | 20 ++++++++++++++------ tests/test_titles.py | 6 ++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/nameparser/parser.py b/nameparser/parser.py index df7ee70..8c988e8 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -1033,7 +1033,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_titles.py b/tests/test_titles.py index 3ad0d91..6d34efd 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -285,3 +285,9 @@ def test_leading_period_abbreviation_suffix_comma(self) -> None: 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) From 0015a93d3f40f3e8190c4914c8384dc6a57c1019 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:44:46 -0700 Subject: [PATCH 4/6] test: cover leading period-abbreviation edge cases and exclusions (#109) --- tests/test_titles.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_titles.py b/tests/test_titles.py index 6d34efd..da95d46 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -291,3 +291,46 @@ def test_leading_period_abbreviation_lastname_comma(self) -> None: 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) From b4c672aeb9f82ea4ad731fedd6290e04f21dd3f3 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:48:01 -0700 Subject: [PATCH 5/6] docs: document leading period-abbreviation title inference (#109) --- docs/release_log.rst | 1 + docs/usage.rst | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/release_log.rst b/docs/release_log.rst index f28aac9..944695c 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 at the start of a name (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..06aad72 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -188,6 +188,35 @@ reading in that ambiguous context: nickname: 'JD' ]> +Leading Period-Abbreviation Titles +----------------------------------- + +An unrecognized, multi-letter word ending in a period, appearing before the +first name, is treated as a title -- this covers military ranks and other +abbreviations that aren't in the built-in titles list. 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 ----------------------------------------------- From 6bba2eb322057c1a863d7c9d108028fab39061fd Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 21:14:56 -0700 Subject: [PATCH 6/6] test: add regex boundary and nickname-interaction coverage; clarify docs (#109) Add tests for the leading-period-abbreviation feature's remaining untested boundaries per PR #196 review: digit/apostrophe exclusion, case-insensitivity, and interaction with parenthetical nicknames. Also tighten doc wording that implied only the literal first token in a name could become a title, when the rule applies to the whole leading title run (chained abbreviations included). --- docs/release_log.rst | 2 +- docs/usage.rst | 13 +++++++------ nameparser/parser.py | 13 ++++++++----- tests/test_titles.py | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/release_log.rst b/docs/release_log.rst index 944695c..b831dcf 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -30,7 +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 at the start of a name (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) + - 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 06aad72..b06513f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -191,12 +191,13 @@ reading in that ambiguous context: Leading Period-Abbreviation Titles ----------------------------------- -An unrecognized, multi-letter word ending in a period, appearing before the -first name, is treated as a title -- this covers military ranks and other -abbreviations that aren't in the built-in titles list. 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. +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 diff --git a/nameparser/parser.py b/nameparser/parser.py index 8c988e8..65934ac 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -543,11 +543,14 @@ def is_title(self, value: str) -> bool: 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."``). 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. + 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)) diff --git a/tests/test_titles.py b/tests/test_titles.py index da95d46..0a95081 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -334,3 +334,27 @@ def test_middle_initial_with_period_unaffected(self) -> None: 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)