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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_

**Unknown-key attribute access on `TupleManager`/`RegexTupleManager` warns (1.4, #256) — 2.0 note: the warning became a hard `AttributeError` naming the known keys (shim `TupleManager`); the paragraph below describes the deleted v1 machinery and applies only when reading v1 history.** — a key not currently in the dict emits `DeprecationWarning` naming the miss and the known keys (`_warn_unknown_key`), before falling back to the same `None`/`EMPTY_REGEX` default as before; `.get()` stays silent. Dunder probes (`__deepcopy__`) still raise `AttributeError` outright, and single-underscore probes (`_repr_html_`, IPython's `_ipython_canary_method_should_not_exist_`, etc.) are excluded from the warning too — no real config key starts with `_`, so both guards just keep protocol/introspection probes from misfiring as "typo" warnings. This means internal parser code that reads `self.C.regexes.<key>` unconditionally (e.g. `squash_bidi`'s `bidi`) now warns if a caller's custom `regexes` dict omits that key — a previously-silent partial-override pattern is on the same deprecation path as an actual typo.

**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PARTICLES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → given-name join, mid-name → family-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `BOUND_GIVEN_NAMES`). Then check the word against real names, not memory: `uv run python tools/name_usage.py <word>` counts its bearers as a given name and as a family name (US Census surnames, SSA given names, Wikidata with countries of citizenship). Pass a name as it is written (`McDonald`): Wikidata labels match exactly, and no rule rebuilds an internal capital from `mcdonald`. It reports and does not decide — read the counts against the position the set's claim acts on. And check the predicates that test a member's SPELLING rather than its membership: they encode what the set's members happened to look like when they were written. `_pieces.starts_a_credential_run` implemented rules.md#S2's "Latin" as `isascii()`, exact until #661 added `mlađi`, which would have read `mlađi Jones` differently from `stariji Jones`; the design-docs review caught it, after a first draft had credited the test to the wrong clause of S2 and proposed deleting it on a mutation that moved nothing because nothing pinned it. Before adding the first member of a new script, or the first accented one, grep the set's consumers for `isascii`, `isalpha` and script tests, and read the rule each one implements.
**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PARTICLES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → given-name join, mid-name → family-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `BOUND_GIVEN_NAMES`). Then check the word against real names, not memory: `uv run python tools/name_usage.py <word>` counts its bearers as a given name and as a family name (US Census surnames, SSA given names, Wikidata with countries of citizenship). Pass a name as it is written (`McDonald`): Wikidata labels match exactly, and no rule rebuilds an internal capital from `mcdonald`. It reports and does not decide — read the counts against the position the set's claim acts on. And check the predicates that test a member's SPELLING rather than its membership: they encode what the set's members happened to look like when they were written. `_pieces.starts_a_credential_run` implemented rules.md#S2's "Latin" as `isascii()`, exact until #661 added `mlađi`, which would have read `mlađi Jones` differently from `stariji Jones`; the design-docs review caught it, after a first draft had credited the test to the wrong clause of S2 and proposed deleting it on a mutation that moved nothing because nothing pinned it. (#679 then dropped the script test on purpose, after measuring what it protected.) Before adding the first member of a new script, or the first accented one, grep the set's consumers for `isascii`, `isalpha` and script tests, and read the rule each one implements.

**Before adding a short/common word to `PARTICLES` globally**, test it mid-string against realistic 3-token names, not just check for English-word collisions: Korean/Vietnamese given names put a short syllable in the middle slot (`Park In Hwan`, `Nguyen To Nga`), and Western names put a bare initial there (`John V. Smith`). A word that looks safe ("nobody is named 'to'") can still swallow a real middle name/initial into the last name once it's a global prefix — confirmed regressions for `to`/`in`/`an`/`ten`/`then` and bare `v` this way (PR #191).

Expand Down
9 changes: 6 additions & 3 deletions docs/design/decisions.md

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions docs/design/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -1193,8 +1193,8 @@ S2. Rationale: generational suffixes and credentials are recognized
word, which reads wholly as the credential run. A member its own
capitals already made the credential reports nothing new.
A credential after the name core starts a run to the end of its
part (#602). An unambiguous Latin suffix word of two or more
letters, standing after two name words where no comma divides
part (#602). An unambiguous suffix word of two or more letters,
in any script, standing after two name words where no comma divides
the name or before a suffix comma, or after the given word in
the part after a family comma, makes every later word of that
part a suffix, except a title word, which reads as a title, and
Expand All @@ -1211,9 +1211,10 @@ S2. Rationale: generational suffixes and credentials are recognized
A title word
never starts the run, and neither does a member of the ambiguous
class, a single letter, a connective, a word that is also
particle or bound given-name vocabulary, or a non-Latin honorific
word: a credential is the writer's mark that the post-nominals
have begun, and those carry no such mark. A name word the run
particle or bound given-name vocabulary: a credential is the
writer's mark that the post-nominals have begun, and those carry
no such mark. An honorific or a generational word carries it as
a credential does, whatever its script. A name word the run
absorbs is reported `suffix-or-name`.
An unlisted word joins this same ambiguous class by SHAPE where
the caller asks for it. Two or more period-separated chunks is
Expand Down Expand Up @@ -1241,6 +1242,11 @@ S2. Rationale: generational suffixes and credentials are recognized
"Antonio Pavlović mlađi" → family="Pavlović"
"Antonio Pavlović mlađi" → suffix="mlađi"
"Агриппина Младшая" → suffix="Младшая"
"جورج بوش الابن" → suffix="الابن"
"ロバート・ダウニー・ジュニア" → suffix="ジュニア"
"Питер Брейгель мл." → suffix="мл."
"Marie Fils" → family="Fils" · boundary
"Jiří Dienstbier ml." → family="ml." · boundary
"Pavlović, Antonio mlađi" → suffix="mlađi"
"Stariji Bruegel" → given="Stariji" · boundary
"John Smith M.A." → suffix="M.A."
Expand Down Expand Up @@ -1309,8 +1315,10 @@ S2. Rationale: generational suffixes and credentials are recognized
"Mohamed Ali Abd Allah" → family="Allah"
"Antonio Pavlović mlađi Jones" → suffix="mlađi Jones"
"Pieter Bruegel stariji Jones" → suffix="stariji Jones"
"Гай Плиний Старший Иванов" → family="Иванов" · boundary
"Taro Tanaka さん Jones" → family="Jones" · boundary
"Ана Хорват старији Џонс" → suffix="старији Џонс"
"Гай Плиний Старший Иванов" → suffix="Старший Иванов"
"राम शर्मा साहब मोहन" → suffix="साहब मोहन"
"राम शर्मा साहब मोहन" → ambiguities=("suffix-or-name",)
"John PhD Smith" → family="Smith" · boundary
Accepted: a title before the credential keeps it a credential,
and the name loses its surname exactly as it did before this
Expand Down Expand Up @@ -1362,14 +1370,6 @@ S2. Rationale: generational suffixes and credentials are recognized
and unchanged (decisions.md#v1-xfail-triage: `king` stays a
title, for the addressing forms).
"Dr Jr" → suffix="Jr"
Accepted: the non-Latin refusal is drawn by script, because the
vocabulary does not say which suffix words are honorifics, so a
generational word in another script does not start the run
either, and Serbian reads by the script it is written in. Only a
name word written after the generational word reaches the
difference (decisions.md#generational-words, open at #679).
"Ana Horvat stariji Jones" → suffix="stariji Jones"
"Ана Хорват старији Џонс" → family="Џонс" · boundary
history: decisions.md#S2 · interacts: H1, H2, H3, H5, C1, C2, S3, P1, P2, P3, P5, P6, M2 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py

S3. Rationale: credentials are often written run together with
Expand Down
2 changes: 1 addition & 1 deletion docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Release Log

**Behavior Changes**

- **Add junior and senior in other languages as suffixes.** ``Antonio Pavlović mlađi`` gives last ``Pavlović``, suffix ``mlađi``, where 1.4.0 through 2.4.2 gave middle ``Pavlović``, last ``mlađi``; the words are Croatian, Serbian, Bosnian, Bulgarian, Russian, Ukrainian, Czech, Slovak, Finnish, Lithuanian and Hindi, written as a separate word after the name. :issue:`661`
- **Add junior and senior in other languages as suffixes.** ``Antonio Pavlović mlađi`` gives last ``Pavlović``, suffix ``mlađi``, where 1.4.0 through 2.4.2 gave middle ``Pavlović``, last ``mlađi``. The words come from 45 languages and are read when written as a separate word after the name. :issue:`661`, :issue:`679`

* 2.4.2 - October 13, 2026

Expand Down
26 changes: 8 additions & 18 deletions nameparser/_pipeline/_pieces.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,20 +466,19 @@ def is_suffix_piece(piece: Sequence[int], ptags: Set[str],
# the given-part walk (mechanisms.md#ONE-PREDICATE-PER-QUESTION)
def starts_a_credential_run(piece: Sequence[int], ptags: Set[str],
tokens: Sequence[WorkToken]) -> bool:
"""Whether one piece starts #602's run: an unambiguous LATIN
suffix word of two or more letters -- a credential acronym or a
generational word -- that no other vocabulary claims as a name.
"""Whether one piece starts #602's run: an unambiguous suffix
word of two or more letters, in any script -- a credential acronym,
a generational word or an honorific -- that no other vocabulary
claims as a name.

`is_suffix_piece` already refuses the ambiguous class and an
initial-shaped letter. Refused here: any single letter (a lowercase
`i` or `v` mid-name is an initial or a connective far more often
than a numeral, and carries no `initial` tag), a connective, a
word that is also bound given-name or particle vocabulary (`abd`
heads `Abd Allah`; `vd`, `mc`), and a word with a letter of another
script, which is what refuses the non-Latin honorifics (`Taro
Tanaka さん Jones` keeps family 'Jones'). Latin means the letters'
Unicode names, not ASCII: #661's `mlađi` starts the run as `stariji`
does (decisions.md#generational-words, #678).
heads `Abd Allah`; `vd`, `mc`). No script is refused: an honorific
starts the run as a credential does (#679, decisions.md#S2), so
`Ана Хорват старији Џонс` reads as `Ana Horvat stariji Jones` does.

The split credential group merges ('Ph. D.', the one piece carrying
the "suffix" piece tag) starts it as its one-token spelling 'PhD'
Expand All @@ -504,16 +503,7 @@ def starts_a_credential_run(piece: Sequence[int], ptags: Set[str],
return False
# C-level, no generator frame per character
letters = "".join(filter(str.isalpha, tok.text.lower()))
return len(letters) >= 2 and (letters.isascii()
or _all_latin(letters))


def _all_latin(letters: str) -> bool:
"""Whether every letter is a Latin one by its Unicode name. Asked
only of a non-ASCII word, so the ASCII test above keeps the common
case free of a frame per letter."""
return all(unicodedata.name(c, "").startswith("LATIN ")
for c in letters)
return len(letters) >= 2


#: tags of suffix words another vocabulary claims as a name word or a
Expand Down
52 changes: 50 additions & 2 deletions nameparser/config/suffixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
# 'ст' especially is a plausible false-positive risk (many two-
# letter Cyrillic abbreviations exist), and both are short enough
# to worry about. Not shipped in this pass. The spelled-out words
# ship since #661 (the block below 'vet'); the abbreviations do not.
# ship since #661 (the block below 'vet'), and мл since #679, which
# found nothing it collides with in Cyrillic; ст stays deferred.
'esq',
'esquire',
'jr',
Expand Down Expand Up @@ -39,7 +40,7 @@
# in decisions.md#generational-words: kıdemli, sóisearach and
# sinsearach (rank words, not generations), Hungarian ifj./id.
# (written BEFORE the name), Bulgarian млада (a given name), and the
# abbreviations ml./мл./st. (the #269 deferral above).
# abbreviations ml./st. (#679 ships мл.; see the deferral above).
'mlađi', 'mlađa', 'stariji', 'starija', # hr/sr/bs Latin
'млађи', 'млађа', 'старији', 'старија', # sr Cyrillic
'младши', 'младша', 'старши', 'старша', # bg; старша is uk too
Expand All @@ -51,6 +52,53 @@
'jaunesnysis', 'jaunesnioji', # lt
'vyresnysis', 'vyresnioji', # lt
'जूनियर', 'जुनियर', 'सीनियर', # hi, junior in both u spellings
# #679: the rest of CLDR's `generation` samples, vetted the same
# way. CLDR is a lead, not a source: where its word is a label
# translation, an adjective written BEFORE the name, or unused, the
# word the language's Wikipedia writes after names ships instead
# (lo, ti, or, ta, si, mk). Transliterations of "senior" ship, but
# not Hebrew סניור, which Israelis bear as a surname (אלי סניור), nor
# Japanese シニア (decisions.md#generational-words); English
# `senior` is not a suffix at all, Senior being a surname.
# Left out with reasons in decisions.md#generational-words,
# including fils/père and the Latin ml./st., which collide outside
# their languages and wait on a pack.
'yngri', 'eldri', # is
'jaunākais', 'jaunākā', 'vecākais', 'vecākā', # lv
'малодшы', 'малодшая', 'старэйшы', 'старэйшая', # be
'помладиот', 'постариот', # mk
'mlajši', 'mlajša', 'starejši', 'starejša', # sl
'mladšia', 'staršia', # sk feminine
'мл', # ru/bg/sr мл.
'τζούνιορ', 'σίνιορ', # el
'կրտսեր', # hy
'უმცროსი', 'უფროსი', # ka
"ג'וניור", 'ג׳וניור', 'ג’וניור', # he, three apostrophes
'הבן', 'האב', # he the son / the father
'الابن', 'الإبن', 'الأب', # ar the son / the father
'جونيور', 'سينيور', # ar
'جونیئر', 'جونيئر', 'جونئیر', 'سینئر', 'سینیئر', # ur/sd
'جونیور', 'سینیور', # fa
'ज्युनियर', 'ज्यूनियर', # mr
'কনিষ্ঠ', 'জ্যেষ্ঠ', 'জুনিয়ৰ', # as
'জুনিয়র', 'সিনিয়র', # bn
'ਜੂਨੀਅਰ', 'ਸੀਨੀਅਰ', # pa
'જુનિયર', 'જૂનિયર', 'સિનિયર', # gu
'ଜୁନିଅର', 'ଜୁନିୟର୍', # or
'இளையவர்', 'மூத்தவர்', 'ஜூனியர்', # ta
'జూనియర్', 'సీనియర్', # te
'ಜೂನಿಯರ್', # kn
'ജൂനിയർ', 'സീനിയർ', # ml
'ජූනියර්', # si
'ジュニア', # ja
'주니어', '시니어', # ko
'จูเนียร์', 'ซีเนียร์', # th
'ຈູເນຍ', # lo
'ဂျူနီယာ', # my
'ၵျူႇၼီႇယႃႇ', # shn
'ជូនៀរ', # km
'ጁንየር', 'ጁኒየር', # ti, am
'ƙarami', # ha the Younger

# #269 follow-up: Hebrew post-nominals, both gershayim spellings
# (ASCII '"' and U+05F4); the mid-word quote is inert in
Expand Down
15 changes: 15 additions & 0 deletions tests/v2/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,21 @@ def _check_cjk_shape_purity(self) -> None:
{"given": "बलबीर", "family": "सिंह", "suffix": "सीनियर"},
classification="fix(#661)",
notes="1.4.0 and 2.4.2 read middle 'सिंह', family 'सीनियर'"),
Case("a_generational_word_in_hangul_comes_off_before_the_order",
"로버트 다우니 주니어",
{"given": "다우니", "family": "로버트", "suffix": "주니어"},
classification="fix(#679)",
notes="the suffix peels before the wholly-hangul name reads "
"family-first by script (rules.md#W4), so the transcribed "
"Robert is the family name as it was before; 2.4.2 read "
"middle '주니어', and 1.4.0 family '주니어'"),
Case("a_generational_word_after_a_nakaguro", "ロバート・ダウニー・ジュニア",
{"given": "ロバート", "family": "ダウニー", "suffix": "ジュニア"},
classification="fix(#679)",
notes="the nakaguro separates tokens, so the katakana Jr. is "
"a word of its own; pure katakana keeps the positional "
"order. 2.4.2 read middle 'ダウニー', family 'ジュニア'; "
"1.4.0 read the whole text as the first name"),
Case("a_title_inside_the_run_is_a_title",
"Eric H. Holder Jr. Attorney General",
{"title": "Attorney General", "given": "Eric", "middle": "H.",
Expand Down
Loading
Loading