Skip to content
Open
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
14 changes: 7 additions & 7 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -157,25 +157,25 @@ Fixing the case of a particular word

``capitalization_exceptions`` is the one pair-valued field — each entry
maps a lowercase key to its exact-cased replacement (``"phd"`` →
``"PhD"``), so it isn't a fit for ``add()``/``remove()``. Change it with
``"Ph.D."``), so it isn't a fit for ``add()``/``remove()``. Change it with
``dataclasses.replace()`` instead, and pass the result to
``capitalized()``:

.. doctest::

>>> import dataclasses
>>> from nameparser import parse
>>> str(parse("jane smith dds").capitalized())
'Jane Smith Dds'
>>> str(parse("jane smith dphil").capitalized())
'Jane Smith Dphil'
>>> default = Lexicon.default()
>>> lex = dataclasses.replace(
... default,
... capitalization_exceptions=tuple(default.capitalization_exceptions)
... + (("dds", "DDS"),))
>>> str(parse("jane smith dds").capitalized(lex))
'Jane Smith DDS'
... + (("dphil", "DPhil"),))
>>> str(parse("jane smith dphil").capitalized(lex))
'Jane Smith DPhil'

Note the ``tuple(...) + ...``: assigning a bare ``(("dds", "DDS"),)``
Note the ``tuple(...) + ...``: assigning a bare ``(("dphil", "DPhil"),)``
would *replace* the default exceptions rather than extend them, so
``"phd"`` and the rest would stop being fixed.

Expand Down
4 changes: 4 additions & 0 deletions docs/design/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,10 @@ R3's earlier history is under `decisions.md#R2`, which this entry does not repea

- 2026-08-29 — WHY THE BOUNDARY WENT UNNOTICED UNTIL #407, which is where a future reader should look for it. For an ALL-PARTICLE part the other three tag-driven views give the same answer through `replace()` and `revise()` alike: measured over `de la`, `van der`, `do`, `de` and `van de la`, all five agree on `family_particles=''`, on a `family_base` holding the whole part, and on initials from every word. They converge because an UNTAGGED part and a MARKED all-particle part reach the same place by different routes — untagged, no word is recognized as a particle; marked, none is ACTING as one — and all three views only ask which words are particles. Case repair is the one view that asks a second question, since it must also decide whether to lowercase, so it is where the two routes first come apart. The mirror case confirms the reading: on a MIXED part the convergence is the other way round — `de la vega` and `van der berg` diverge in all three views between `replace()` and `revise()` (`replace()` reports particles `''` and base `'de la vega'` where `revise()` reports `'de la'` and `'vega'`) and AGREE on case repair, R4's all-particle clause not reaching them. So before #407 the distinction was invisible on exactly the shape the clause is about, and visible only on shapes the clause does not govern.

- 2026-09-13 #459 — DECIDED: a credential acronym the exceptions map does not carry is an initialism, so a single-case word the parse put in the SUFFIX role from `suffix_acronyms` repairs to its all-caps spelling rather than a title-cased one (the clause in `_render._cap_word`, after the exceptions-map lookup and before the Mac/Mc rule). The scope is narrow on purpose. The exceptions map is consulted first, so the five entries that need a non-all-caps spelling (`md` → M.D., `phd` → Ph.D., and the roman numerals) keep theirs and the clause never touches them. The repair is gated on the SUFFIX role, so a word that is in the acronym vocabulary but parsed as an ordinary name word (`anh van do` → `Anh Van Do`) still repairs as that name word -- the gate is the whole reason the fix is safe on surnames that share a spelling with a credential. The wider design the issue proposed (letter masks, `md` leaving the map, the given-role half of `QC MP`) stays on the rescoped #459; this clause is the narrow part #459 already accepts, not a re-litigation of those.
Reach, population first: measured on the PR head over the differential corpora (1143 names), 122 names carry acronym vocabulary in a suffix token and 11 of those are written in a single case, so the honest reach is 5 of the 11 eligible on the default `capitalized()` path and 71 under `force=True`. Recompute by swapping the pre-change `_cap_word` -- the one without the clause, `git show d37b8ec:nameparser/_render.py` -- in for the changed one in a single process, then diffing `capitalized()` and `force=True` over the deduped corpora (the 1143-name population is the differential corpora at the released baselines).
Accepted costs, deferred to the rescoped #459 rather than relitigated here: the all-caps default reaches words conventionally written mixed-case -- `bsc`/`msc` read `BSC`/`MSC` under `force=True`, and `Dr. med. univ. Margit Popp, MSc` is a corpus name that reads `MSC` -- which the letter-mask design #459 defers is meant to recover; `ii`/`iii`/`iv` are `suffix_words` rather than acronyms and need the exceptions map precisely because the clause would not see them there; and because the ambiguous five (`ba`, `do`, `ed`, `jd`, `ma`) are in `suffix_acronyms`, the clause moves WHICH parse triggers the repair rather than preventing it -- `john smith ed` → `John Smith ED`, `john smith ba` → `BA`, and `smith, ms.` → `MS.` on the default path. That is the same #342/#454-class cost #459 already accepts, and the alternative (reading classify's `vocab:suffix` tag) was measured and costs `jd` → `Jd`, so the role gate is the right instrument.

### R5 — the case-repair gate

- 2026-08-29 (#407 arc) — EXTRACTION, not a decision: the parser is untouched. The two halves of R5 have separate provenance, and conflating them is easy enough that the first draft of this entry did. The REFUSAL — repair skips any name already carrying more than one case — is older than the git history: `git log -S "name == name.upper() or name == name.lower()" --reverse` bottoms out at 45a1539 (2011-02-03), the initial import from svn, where `capitalize()` already opens with that guard and a bare `return`. (A path-filtered search answers 280895b instead, the same-day commit that moved the module into `nameparser/`; the code did not change there.) The OVERRIDE is bf1e0a5, 2016-06-02, which did not add the refusal but wrapped it — `if not (name == name.upper() or ...)` became `if not force and not (...)` — and shipped in 0.4.0 (June 2, 2016; its own release-log line is docs/release_log.rst under that heading). So R5's statement as a whole holds from 0.4.0 on. rules.md had never said any of it, though bf1e0a5's diff shows the API docstring already did: "It will not adjust the case of names entered in mixed case" was there before that commit edited around it. The gap was rules.md's alone. Evidence, measured on the released 1.4.0 wheel — the last v1 release and one of the differential baselines, so a natural thing to measure against and not a release that introduced anything here — and re-measured on this branch today, facade and core agreeing: `HumanName('Shirley Maclaine').capitalize()` leaves `'Shirley Maclaine'` — mixed case, wrong, and kept — while the same name under `force=True` gives `'Shirley MacLaine'`; `HumanName('Juan McDonald').capitalize()` leaves `'Juan McDonald'`. rules.md's own preamble classifies behavior in this position as "pinned-but-undocumented — an extraction gap to close, not a specification", which is why the fix lands in the document rather than in `_render.py`.
Expand Down
10 changes: 9 additions & 1 deletion docs/design/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -1670,12 +1670,20 @@ R4. Rationale: case repair is a display concern, applied only on
conventions rather than by the bearer's. A spelling written in a
single case is repaired even where its bearer meant it, because
nothing in the text marks it as a choice; where the text does
mark one, R5 defers to it.
mark one, R5 defers to it. A credential acronym the exceptions map
does not carry is an initialism, so a single-case word the parse
put in the suffix role from the acronym vocabulary repairs to its
all-caps spelling rather than a title-cased one; a word in that
vocabulary that parsed as an ordinary name word repairs as that
name word, and a suffix word that is not an acronym -- the
generational `jr`, `sr` -- keeps its title case.
"juan mcdonald" → capitalized="Juan McDonald"
"Juan McDonald" → capitalized_forced="Juan McDonald"
"ANH DO" → capitalized="Anh Do"
"anh van do" → capitalized="Anh Van Do"
"john smith phd" → capitalized="John Smith Ph.D."
"john smith mba" → capitalized="John Smith MBA"
"john smith jr" → capitalized="John Smith Jr" · boundary
"juan de la vega" → capitalized="Juan de la Vega" · boundary
Accepted: the clause reaches a part the parser read. A field
spliced in as raw text after the parse carries no reading of its
Expand Down
2 changes: 2 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Release Log

**Behavior Changes**

- **Repair a credential acronym the case-repair exceptions map does not carry to all-caps instead of title-casing it.** ``HumanName("JOHN SMITH MBA").capitalize()`` gives ``John Smith MBA`` where every release since 1.4.0 gave ``John Smith Mba``; ``john smith jd`` gives ``John Smith JD``. The repair is keyed on the word having parsed in the suffix role from the acronym vocabulary, so a word that is an ordinary name merely sharing a spelling with an acronym is untouched, and the exceptions map still wins first -- ``john smith md`` gives ``John Smith M.D.`` and ``john smith phd`` gives ``John Smith Ph.D.`` as before, and the generational ``jr`` is unaffected (``john smith jr`` gives ``John Smith Jr``). The given-name half of a mixed run is unchanged, so ``QC MP`` gives ``Qc MP`` with the ``QC`` (given role) still title-cased and only the ``MP`` (suffix role) repaired. Five names move in the differential corpora on the default ``capitalize()`` path (seventy-one under ``force=True``); no other field view moves. See the ``R4`` entry of ``docs/design/decisions.md`` (#459)

- **Fix HumanName.initials() dropping a middle- or family-group initial that is also a one-letter conjunction.** ``HumanName("Scott E. Werner").initials()`` gives ``S. E. W.`` again where 2.0.0 through 2.2.0 gave ``S. W.``; ``Juan Y. Garcia`` and a bare ASCII capital ``John E Smith`` likewise. v1 excluded initial-shaped words from its conjunction test and the 2.0 facade had not; ``parse(...).initials()`` was already right and is unchanged. A bare lowercase ``john e smith`` still reads the ``e`` as the connective. See the ``R3`` entry of ``docs/design/decisions.md`` (closes #462)

- **Record a 2.0.0 change to HumanName.initials() that no release note had classified:** since 2.0.0 the facade initials each WORD of a name part, where 1.4.0 initialed a joined run as one group -- ``HumanName("Juan Velasquez y Garcia").initials()`` is ``J. V. G.`` and was ``J. V G.``; ``Abdul Salam Hassan`` is ``A. S. H.`` and was ``A S. H.``. Nothing changes in 2.3.0; the differential gate now compares ``initials()`` (#484) and this is what it found. See the ``differential-ledger, the initials view`` entry of ``docs/design/decisions.md``
Expand Down
15 changes: 15 additions & 0 deletions nameparser/_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,21 @@ def _cap_word(word: str, role: Role, tags: frozenset[str],
exception = lex.capitalization_exceptions_map.get(key)
if exception is not None:
return exception
# A credential acronym the exceptions map doesn't carry (mba, jd,
# qc, mp, ...) is an initialism, not a word to title-case: a one-
# case name repairs to the acronym's caps instead of 'Mba' (#459).
# The exceptions map is consulted first and holds the entries that
# spell differently -- md -> M.D. and phd -> Ph.D. (the generational
# ii/iii/iv are suffix_words, not acronyms, and ride the map because
# str.capitalize() would give 'Ii'). The all-caps default is the
# right call for an initialism; its cost is that an acronym
# conventionally written mixed-case (bsc, msc) reads all-caps here
# (BSc -> BSC under force) rather than mixed, which the letter-mask
# design deferred to #459 is meant to recover. Gated on the SUFFIX
# role so a word that is a family name only happens to be in the
# vocabulary (anh van DO) still repairs as an ordinary name word.
if role is Role.SUFFIX and normalized.replace(".", "") in lex.suffix_acronyms:
return word.upper()
if _MAC.match(word):
return _MAC.sub(
lambda m: m.group(1).capitalize() + m.group(2).capitalize(),
Expand Down
1 change: 0 additions & 1 deletion nameparser/config/suffixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,6 @@
'pfmp',
'pg',
'pgmp',
'ph',
'pharmd',
'phc',
'phd',
Expand Down
32 changes: 32 additions & 0 deletions tests/test_capitalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ def test_capitalize_suffix_acronym_with_dots(self) -> None:
hn.capitalize()
self.assertEqual(hn.suffix, 'M.D.')

# A credential acronym the exceptions map doesn't carry is an
# initialism, so a one-case suffix repairs to all-caps instead of
# title-case (issue #459).
def test_capitalize_suffix_acronym_is_all_caps(self) -> None:
for src, expect in [
('JOHN SMITH MBA', 'John Smith MBA'),
('john smith jd', 'John Smith JD'),
('JOSE LUIS CPA', 'Jose Luis CPA'),
('john smith pmp', 'John Smith PMP'),
]:
hn = HumanName(src)
hn.capitalize()
self.m(str(hn), expect, hn)

# The exceptions map's five keep their special casing; the new
# all-caps path must not shadow them (#459).
def test_capitalize_exceptions_still_win_over_acronyms(self) -> None:
for src, expect in [
('john smith md', 'John Smith M.D.'),
('john smith phd', 'John Smith Ph.D.'),
]:
hn = HumanName(src)
hn.capitalize()
self.m(str(hn), expect, hn)

# A word in the acronym vocabulary that parses as a family name
# still repairs as an ordinary name word, not an acronym (#459).
def test_capitalize_family_name_in_acronym_vocab_stays_title_case(self) -> None:
hn = HumanName('anh van do')
hn.capitalize()
self.m(str(hn), 'Anh Van Do', hn)

# Leaving already-capitalized names alone
def test_no_change_to_mixed_chase(self) -> None:
hn = HumanName('Shirley Maclaine')
Expand Down
2 changes: 2 additions & 0 deletions tools/differential/corpus_rules.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@
"de la Vega y Santos Juan"
"de los Santos"
"ibn Awf abdul Rahman"
"john smith jr"
"john smith mba"
"john smith phd"
"juan de la vega"
"juan mcdonald"
Expand Down