Skip to content

Rename the vocabulary data modules to the 2.0 terminology, with a 2.x bridge (#293) - #354

Merged
derek73 merged 15 commits into
masterfrom
claude/issue-293-vocabulary-rename
Aug 9, 2026
Merged

Rename the vocabulary data modules to the 2.0 terminology, with a 2.x bridge (#293)#354
derek73 merged 15 commits into
masterfrom
claude/issue-293-vocabulary-rename

Conversation

@derek73

@derek73 derek73 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #293.

The 2.0 API named its concepts for what they are — particles, bound_given_names, given_name_titles, suffix_words — while the nameparser/config data modules feeding them kept the 1.x names. This moves the data layer to match, keeps every 1.x name alive through 2.x as a warning alias, and freezes the vocabulary constants.

The renames

Old New
config/prefixes.py / PREFIXES config/particles.py / PARTICLES
NON_FIRST_NAME_PREFIXES NON_GIVEN_NAME_PARTICLES
config/bound_first_names.py / BOUND_FIRST_NAMES config/bound_given_names.py / BOUND_GIVEN_NAMES
FIRST_NAME_TITLES (stays in titles.py) GIVEN_NAME_TITLES
SUFFIX_NOT_ACRONYMS (stays in suffixes.py) SUFFIX_WORDS

The never-given set is renamed, not inverted — it stays the stored source with Lexicon.particles_ambiguous derived from it, per the issue's reasoning: adding a particle stays safe-by-default, and the v1 shim needs the complement in that direction through 2.x.

The v1 Constants attribute names (prefixes, first_name_titles, bound_first_names, non_first_name_prefixes, suffix_not_acronyms) are facade surface and are unchanged.

The bridge

config/_deprecated.py builds a PEP 562 __getattr__/__dir__ pair — same mechanism as nameparser/locales/__init__.py. Each 1.x name resolves to its 2.2 constant, warns once per name per process naming the new path and the 3.0 removal, then writes back so the name becomes an ordinary global.

__all__ on each alias-bearing module covers from ... import *, which __getattr__ alone does not reach — without it a star import bound nothing and leaked the helper, with no warning at all.

The freeze

All eleven vocabulary set constants are now frozenset. This closes a real desync, measured against the pre-freeze tree and the released v2.1.0 tag: Lexicon.default() is functools.cached and reads the modules once, while v1 Constants copy at every construction, so whether TITLES.add("dean") reached a given parse depended on which config objects had already been built. It now raises at the mutation. CAPITALIZATION_EXCEPTIONS (a dict) is unchanged.

Verification

pytest            3103 passed, 20 skipped, 11 xfailed
mypy              Success: no issues found in 103 source files
ruff              All checks passed!
sphinx -b html    build succeeded, 0 warnings
sphinx -b doctest 223 tests, 0 failures

Behavior-neutrality was measured, not assumed. Each rename was proven set-identical against its predecessor blob, and 751 distinct names from all three differential corpora were parsed through both APIs on branch and master: 0 diffs across all seven fields, with a planted diff proving the harness could report one.

Corrections this bundle makes beyond the rename

Reviewing the prose being renamed turned up claims that were plausible and false. Each was measured before rewriting:

  • AGENTS.md's esq gotcha. It said Esq matches only through the word set and that removing either membership drops a spelling. Esq has no interior period, so it hits the acronym branch too; removing 'esq' from the word set changes no parse. The acronym membership is the load-bearing one.
  • BOUND_GIVEN_NAMES' example. "abdul salam" never joined — the bound join needs three non-title, non-suffix pieces in a main segment (two after a family comma).
  • "Never in the family-name region" — falsified by Policy(name_order=FAMILY_FIRST), which shipped in 2.1. The join is a grouping-stage rule that runs before roles exist.
  • PARTICLES' "attach to the family name"parse("Smith, Juan de la Cruz") gives middle de la Cruz.
  • SUFFIX_WORDS' "the parser does not remove periods" — false for edge periods; 'Junior.' normalizes and matches.

What a reviewer should look at first

  1. nameparser/config/_deprecated.py and the four alias sites — the bridge's contract, including the __all__ star-import coverage.
  2. nameparser/_lexicon.py:614-655 and _config_shim.py:1062-1077 — where the freeze's blast radius lands, as the frozenset(...) wraps drop and module objects go straight into the Lexicon.
  3. docs/release_log.rst — the only user-facing artifact of the branch.

Review round

A five-agent review (comment accuracy, test coverage, silent failures, type design, general code review) ran against the branch. Four follow-up commits landed from it; every finding below was measured, not argued.

Two design changes.

  • The bridge now warns once per read-location, not once per process. The write-back made the first reader anywhere in the interpreter consume the only warning — a vendored dependency importing PREFIXES silenced it for the file you actually have to edit. Dropping it lets Python's own __warningregistry__ dedupe per call site: two callers now get two warnings, repeats from the same line stay suppressed. This amends the issue's stated "once per process per name" requirement, deliberately.
  • titles.py and suffixes.py keep their missing-attribute checking. A module __getattr__ disabled mypy's attr-defined diagnostic for both, so from nameparser.config.titles import TITLE type-checked clean on a py.typed package. Declaring the retired names under if TYPE_CHECKING: restores the diagnostic and types the aliases as frozenset[str] instead of Any. Four # noqa: F822 became unnecessary and were removed.

Two hardening commits.

  • Closed three gaps the guards left open: __dir__ could drop every live name with 2260 tests still green; the frozen-constant roster used glob rather than rglob and a presence check a single constant satisfied; and an alias-table row missing from __all__ reproduced the exact bug an earlier commit fixed, silently. Each fix was proven non-vacuous by planting the regression.
  • Corrected eight claims measured false, including three the PR itself wrote. The sharpest: SUFFIX_WORDS' docstring said "J.u.n.i.o.r." does not match, but it parses as a suffix because of this setperiod_joined_vocab splits interior-period tokens and 'i' is Roman numeral one. "J.u.n.o.r." is the example that actually isolates this branch. Also: a leading-particle paragraph falsified by Policy(name_order=FAMILY_FIRST), an autodoc mechanism claim (the docs build does resolve the retired names, emitting two DeprecationWarnings), and a comment claiming Lexicon re-checks GIVEN_NAME_TITLES ⊆ TITLES under python -O when that check is deliberately absent.

Each of the two module renames is split into a pure git mv commit followed by a separate commit adding the shim at the old path. Git records no renames — it re-derives them at diff time by pairing a deleted path against an added one — so recreating prefixes.py as a shim in the same commit left particles.py with no rename source and started its git blame at the rename. Split, blame follows the vocabulary back to where each word entered the project ('santa' → 2015), and through bound_first_names.py's own earlier rename from first_name_prefixes.py.

All 15 commits pass pytest independently (3076 → 3109, monotonic), verified in a throwaway worktree.

Follow-ups deliberately not in scope

  • On merge, add the 3.0 alias removal to the v3.0 milestone. The sweep must delete config/prefixes.py, config/bound_first_names.py, config/_deprecated.py, the TYPE_CHECKING/alias blocks in titles.py/suffixes.py, tests/v2/test_config_aliases.py, migrate.rst's bridge section, and the retired-name half of AGENTS.md.
  • Where the data modules live in 3.0 is open — the migration spec says nameparser.config goes "in its entirety" while enumerating only the five shim names. That now has a consequence, since Lexicon's public field docs cross-reference nameparser.config.particles.
  • Particle docs name the field a leading particle lands in, which is only true under the default name order #355 — five more places state leading-particle behaviour in field terms that FAMILY_FIRST falsifies. Wants one sweep, not five arbitrary fixes.
  • Housekeeping left over from the #293 vocabulary rename #356 — housekeeping minors from the review.

🤖 Generated with Claude Code

@derek73 derek73 added enhancement breaking-change Backwards-incompatible API change labels Aug 9, 2026
@derek73 derek73 self-assigned this Aug 9, 2026
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.50%. Comparing base (18b0e49) to head (f653b69).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #354      +/-   ##
==========================================
+ Coverage   98.48%   98.50%   +0.01%     
==========================================
  Files          41       44       +3     
  Lines        2845     2881      +36     
==========================================
+ Hits         2802     2838      +36     
  Misses         43       43              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

derek73 and others added 15 commits August 9, 2026 12:05
The 2.0 API named this concept for what it is -- Lexicon.particles and
Lexicon.particles_ambiguous -- while the data module feeding them still
spoke 1.x: PREFIXES and NON_FIRST_NAME_PREFIXES in config/prefixes.py.
This moves the data layer to match: the module is now
config/particles.py, exporting PARTICLES and NON_GIVEN_NAME_PARTICLES.
The set members are untouched, so parsing is unchanged by construction;
only the names, the docstrings' cross-references and the import-time
assertion messages move.

This commit is the rename ALONE, and the old import path is broken
between here and the next commit. That is deliberate: git records no
rename, it infers one at diff time by pairing a deleted path with an
added one, so re-creating config/prefixes.py as a shim in this same
commit would leave nothing to pair and git blame on config/particles.py
would begin here instead of following the vocabulary back to where each
word entered the project. That trail is how questions like "why is
'santa' a prefix" get answered. The bridge follows in the next commit,
where config/prefixes.py is genuinely a new file with no history worth
keeping. bound_first_names.py, itself renamed from first_name_prefixes.py
in 1.x, is the precedent: blame walks straight through it.

The v1 Constants attribute names (prefixes, non_first_name_prefixes) are
untouched -- they are facade surface, not data-layer names -- so
_default_vocab()'s dict keeps its keys and only the values move.

The PARTICLES docstring is rewritten rather than translated. Its opening
claim, that particles "only appear in middle or last names", is
contradicted by this file's own #269 comment and by the parser: a
leading particle chains nothing, and one outside NON_GIVEN_NAME_PARTICLES
is read as a given name ("Van Johnson") with a particle-or-given
ambiguity recorded for the reading not taken. The new text states the
non-leading pull-forward, the leading exception, and both of its
branches.

tests/test_prefixes.py is renamed to match the module it exercises,
along with its TestCase class; the v1 Constants uses inside it stay as
they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config/prefixes.py returns as a shim -- a new file, no data, nothing of
its predecessor to preserve, which is why the rename went in ahead of it
as its own commit. Its module __getattr__ (PEP 562, the mechanism
nameparser/locales/__init__.py already uses) resolves each 1.x name to
its new constant and warns once naming the replacement path. The bridge
itself lives in config/_deprecated.py, which the remaining vocabulary
renames reuse as they land; the whole layer is deleted in 3.0 with the
rest of the v1 facade.

Its __getattr__ returns Any, PEP 484's convention for a module
__getattr__: mypy honors the assigned one, and returning object would
have typed every deprecated name as unusable for the callers still on
the old path -- an error about object rather than a word about
deprecation.

tests/v2/test_config_aliases.py pins the bridge: each alias resolves to
the identical object, the message names both the old and the new path
plus the removal release, an unknown attribute still raises
AttributeError, and dir() advertises the old names. Two more tests pin
the warning's attribution -- the recorded frame must be the caller's
line, including through a real `from nameparser.config.prefixes import
PREFIXES` -- since a wrong stacklevel is invisible from inside the
warning call and #337 is the scar from that regressing unnoticed. The
alias table is written out literally rather than imported from the shim,
so the assertions describe where the migration guide points instead of
merely proving the shim self-consistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second vocabulary through the bridge the previous pair built, and split
the same way and for the same reason: the rename alone here, so git can
pair a deleted path with an added one and blame keeps following the
entries back past this commit; the shim at the old path in the next one,
where it is a genuinely new file. config/bound_first_names.py becomes
config/bound_given_names.py exporting BOUND_GIVEN_NAMES, matching
Lexicon.bound_given_names, the field it has always fed. The eleven
entries are byte-identical -- only the constant name, the
assert_normalized label, the docstring and the cross-references in
particles.py, _lexicon.py and _config_shim.py move -- so parsing is
unchanged by construction.

The v1 Constants attribute bound_first_names is untouched -- facade
surface, not a data-layer name -- so _default_vocab()'s dict keeps that
key and only its value moves. tests/test_bound_first_names.py is
renamed to match the module it exercises, along with its TestCase class;
the v1 Constants uses inside it stay as they were.

The docstring is rewritten rather than translated, because both claims
it inherited were false. "abdul salam" does not parse to the given name
"abdul salam": the join reserves a piece for what follows, so a bare
two-word name gives given "abdul" plus family "salam" and it takes
"abdul salam smith" to get the joined name the docstring advertised.
And the join is not confined to a given-name region -- it is a
group-stage rule on the first non-title piece, running before roles
exist and consulting no name_order, so under FAMILY_FIRST the very same
join produces family "abdul salam" ("abdul salam smith" -> given
"smith"). The replacement states the mechanism and the two thresholds
rather than a region: three pieces that are neither title nor suffix in
a main segment (BoundJoin.STRICT -- "dr. abdul salam" and "abdul salam
jr" both fall short, one for the title and one for the suffix), and two
after a family comma where the family name is already fixed
(BoundJoin.LENIENT -- "salam, abdul rahman" -> given "abdul rahman",
pinned by test_lastname_comma_join). The entries are called prefixes,
not particles, so the word the previous commit defined for the
family-name vocabulary is not overloaded in the file whose sister module
asserts the two sets stay disjoint -- and which three entries, abu and
its two Arabic spellings, belong to both.

PARTICLES' own docstring had the same overreach one file over and is
fixed in the same commit: it opened "Name pieces that attach to the
family name", but "Smith, Juan de la Cruz" chains the identical run
into the MIDDLE name under default policy. It now leads with the
mechanism -- a particle joins the piece that follows it -- and shows
both landings. The leading-particle paragraph below it is left alone
here; a later commit in this bundle scopes it to the default order.

Two comments describing particles.py's disjointness assert in v1
vocabulary (_config_shim._snapshot, test_snapshot_keeps_a_bound_never_
given_prefix_parseable) now name the constants that assert actually
uses, keeping the v1 attribute names only where they describe what a v1
caller can do at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config/bound_first_names.py returns as a shim over
config/_deprecated.alias_getattr, resolving BOUND_FIRST_NAMES to its new
home, warning once at the caller's frame and naming 3.0 as the removal.
A new file with no predecessor to preserve, which is why the rename went
in ahead of it.

tests/v2/test_config_aliases.py gets a row in its literal ALIASES table,
which puts the new alias through every assertion the particle aliases
already face: identity with the new constant, both paths named in the
message, AttributeError preserved, dir() advertising the old name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third vocabulary through the bridge Task 1 built, and the first that
does not move house. config/titles.py keeps its name and both its sets;
only the sub-set renames, to match Lexicon.given_name_titles, the field
it has fed since 2.0. The 35 entries and the 711-word TITLES union it
feeds are byte-identical -- the constant name, the #269 comment, the
subset assert and its message, and the cross-references in _lexicon.py,
_config_shim.py and particles.py are the whole change -- so parsing is
unchanged by construction.

Because the constant stayed put there is no shim module to add: titles.py
grows the alias __getattr__ itself, aliasing FIRST_NAME_TITLES to a name
in its own globals. That is not circular. A module __getattr__ runs only
after the module body has finished and the module is in sys.modules, so
the getattr() inside the bridge finds GIVEN_NAME_TITLES as an ordinary
global; the write-back then makes FIRST_NAME_TITLES one too. Checked
directly rather than assumed: an absent attribute raises AttributeError
rather than recursing, and dir() lists both names. Keeping the shared
helper here rather than writing a two-line direct alias is the point --
one bridge, one message format, one row per alias in the test table.

The v1 Constants attribute first_name_titles is untouched -- facade
surface, not a data-layer name -- so _default_vocab()'s dict keeps that
key and only its value moves. _snapshot() needs no change: it reads the
v1 SetManager, never the constant.

One stale cross-reference goes with it. The assert block's "(see
prefixes.py)" pointed at the import-time asserts at the bottom of that
module, which Task 1 emptied into a shim; the asserts it means now live
in particles.py, so the comment names that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth vocabulary through the bridge, and the second in-place rename.
config/suffixes.py keeps its name and all four of its sets; the
word-matched one takes the name of the Lexicon field it feeds,
suffix_words. All 40 entries and the sets around it are byte-identical
-- the constant name, the two docstrings and three comments that cite
it, the two asserts and their messages, and the cross-references in
_lexicon.py, _config_shim.py and test_ledger_guards.py are the whole
change -- so parsing is unchanged by construction.

The 1.x name defined the set by what it is not, and the definition was
wrong: 'esq' is in SUFFIX_ACRONYMS too. What actually separates the two
is how each folds the token it matches, which is what the new name
says. As with GIVEN_NAME_TITLES the constant did not move module, so
suffixes.py carries its own alias __getattr__ over the shared helper;
an absent attribute still raises AttributeError rather than recursing,
and dir() lists both names.

The set's own docstring is rewritten, not translated, because its claim
was false. "The parser does not remove periods when matching against
these pieces" is true of INTERIOR periods only: the word branch matches
_normalize(text), which strips edge periods to a fixed point, so
"Junior." matches this set and "J.u.n.i.o.r." does not (measured
through suffix_as_written, not inferred).

Three comments carried a second false claim, and this commit corrects
all three rather than renaming them into agreement. The story was that
'esq' sits in both sets to cover one spelling each -- "Esq" as a word,
"E.S.Q." as an acronym. It does not. The acronym branch strips every
period from the token before lookup, so it matches "Esq" as readily as
"E.S.Q."; the word membership adds nothing over the shipped acronym
set. Measured both directions over 63 spellings x frames, and through
the v1 facade as well: removing 'esq' from suffix_words changes 0 of
63 parses, while removing it from suffix_acronyms changes 18 -- every
interior-period spelling, including "John Smith E.S.Q." falling to
family='E.S.Q.'. Not an accident of one word, either: the two sets
intersect in exactly {'esq'}, no SUFFIX_WORDS entry carries an interior
period, and the assert next door bars an entry in both from the
period-gated ambiguous subset, so the acronym branch fires wherever the
word branch does for anything in both.

What the word membership IS good for survives the correction, and the
comments now say that instead: these sets are caller-editable, and once
'esq' leaves SUFFIX_ACRONYMS the word entry is what still matches "Esq"
(verified). So nothing here is a duplicate to clean up, and the acronym
membership stays load-bearing -- it is the only thing matching the
multi-dot spelling. That is the real reason the two sets are not
asserted disjoint, and it is now stated where the data is.

AGENTS.md carries the strongest version of the same wrong claim; it is
out of scope here and belongs to the docs commit, which should correct
that gotcha rather than mechanically rename it.

The v1 Constants attribute suffix_not_acronyms is untouched -- facade
surface -- so _default_vocab()'s dict keeps that key and only its value
moves. _snapshot() needed no change: its honorific_tails intersection
already reads the v1 SetManager and the v2 field name, never the raw
constant. The ledger guard's _HONORIFIC_SOURCES roster moves with it,
since it names the constant a toml alternation is a hand copy of; the
ledgers' own comments are docs and are left for the docs commit.

One stale cross-reference goes with it, as in the previous commit: the
assert block's "same rationale as prefixes.py" now names particles.py,
where those asserts live since Task 1 emptied prefixes.py into a shim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge in config/_deprecated.py exists for callers, not for us, and
nothing so far says so. `filterwarnings = ["error"]` catches an internal
read of a 1.x name only if some test happens to walk that path, and it
says nothing at all about a NEW internal consumer reaching for one.

The write-back cache makes a stale internal reference worse than noisy.
Each alias warns once per process and is then an ordinary module global,
so whoever reads it first consumes the only warning -- an internal read
at import time would spend it before any caller's code runs, and the
downstream author the message is written for would be told nothing.

So scan the source instead: every .py under nameparser/, checked against
a table of the five retired names and the files each may appear in. The
match is raw text rather than a token, which also catches a comment or a
docstring left naming a constant that no longer exists; config/
_deprecated.py joins the allow-list on that account, since its
stacklevel comment quotes a `from ... import PREFIXES` line as its
worked example. Allow-listed by package-relative path, not by filename,
so a future locales/titles.py does not inherit config/titles.py's
exemption.

The roster is the one thing here that could fail open, so the test also
asserts it SAW each retired name somewhere: every one is spelled in its
own alias table, so a name the scan never encountered means the scan is
broken rather than the tree clean. Verified by planting `PREFIXES = 1`
in a package file, which fails with
`['_vacuity_probe.py: PREFIXES']`.
The config modules exported plain sets, and the two APIs read them on
different schedules: Lexicon.default() is functools.cached and reads
each set ONCE, while the v1 shim's Constants copy from them at every
construction. Mutating a module set therefore did not change "the
default" -- it changed whichever defaults had not been built yet.

Measured against the pre-freeze tree: after one parse has warmed the
caches, TITLES.add("dean") is picked up by HumanName(name, Constants())
and by nothing else. The shared CONSTANTS copied at import and the
cached Lexicon.default() both predate the edit, so the same program
parses the same name two ways depending on which config object it holds.
That is not a documented knob failing at its edges; it is one program
holding two disagreeing defaults with nothing to say so.

So the sets are frozensets, and the mutation raises where it is
written. TITLES and PARTICLES freeze by construction rather than by
wrapping -- both are defined as a union with a set literal, and
frozenset.__or__ returns a frozenset -- which is now noted at each,
along with the operand order that keeps it true. surnames.py has been
born frozen since it landed, citing this commit's convention; it is no
longer the exception, and its comment now states the rule without the
contrast.

The v1 mutation surface is untouched: SetManager copies its input
through _normalize_iterable_of_strings into a fresh mutable set. Both
replacements the release log offers are verified to parse correctly and
emit no warnings: a private `c = Constants(); c.titles.add("dean")`
passed as HumanName(constants=c), and Lexicon.default().add(
titles={"dean"}) on the 2.0 side. Mutating the shared CONSTANTS still
works too, but warns -- it is on its own 3.0 removal path -- so it is
mentioned rather than recommended. CAPITALIZATION_EXCEPTIONS is out of
scope: it is a mapping, and MappingProxyType at module level has
pickling wrinkles of its own.

Two comments promised their frozenset() wraps would drop when this
landed, so they do: _lexicon._default_lexicon() and
_config_shim._snapshot() now pass the constants through, and the
contrasts those comments drew between the born-frozen surnames module
and its mutable neighbours are gone with the distinction. Types follow
-- _default_vocab() returns dict[str, frozenset[str]], and the ledger
guards' rosters that hold these constants say frozenset[str] too.

The new test derives its roster from the source tree rather than
listing it, so the next module or the next constant cannot fail open.
Its RED run named all twelve: bound_given_names.BOUND_GIVEN_NAMES,
conjunctions.CONJUNCTIONS, maiden_markers.MAIDEN_MARKERS,
particles.{BOUND_GIVEN_NAMES,NON_GIVEN_NAME_PARTICLES,PARTICLES},
suffixes.{GLUED_HONORIFICS,SUFFIX_ACRONYMS,SUFFIX_ACRONYMS_AMBIGUOUS,
SUFFIX_WORDS} and titles.{GIVEN_NAME_TITLES,TITLES}.
Two loose ends from the guard as it landed.

The allow-list carried config/_deprecated.py for PREFIXES, because the
stacklevel comment there used `from nameparser.config.prefixes import
PREFIXES` as its worked example. But that comment is about which FRAME
touched a deprecated name; which name it was is incidental. So the
example drops the constant and keeps the module, the exemption goes,
and the invariant is crisp again: the only file that may spell a
retired name is the alias table serving it. The exemption as landed
outlived its reason, was invisible from the comment's side, and covered
the one file whose whole subject is the old names -- the likeliest
place for a real shortcut to grow unnoticed.

Note the example names the module rather than the 2.2 constant: a
`from nameparser.config.particles import PARTICLES` never reaches this
__getattr__ at all, so it would illustrate the wrong path. The comment
now points at the test that constrains it, since the constraint is
otherwise unguessable from that file.

The comment on _RETIRED_NAMES documented one substring hazard (a hit on
NON_FIRST_NAME_PREFIXES is also a hit on PREFIXES) but not the class
that misleads: an unrelated identifier that merely contains a retired
name. Planting `TITLE_PREFIXES = ("dr",)` in a package file reports
`['_fp_probe.py: PREFIXES']` and tells the author to move it to its 2.2
name, which for that file is wrong advice -- and PREFIXES is generic
enough that a TITLE_PREFIXES or LOCALE_PREFIXES is a plausible thing to
write. Both hazards are now listed, with the note that neither can hide
a real hit, and the failure message offers the allow-list as the other
remedy so the reader is not pushed toward a rename that makes no sense.

The bluntness itself stays deliberate: matching raw source rather than
tokens is what lets the guard catch a docstring left naming a constant
that no longer exists.
The API reference still pointed `automodule` at `config.prefixes` and
`config.bound_first_names`, which are now data-free shims: the particle
and bound-given-name vocabularies rendered NOWHERE in modules.html.
Measured before/after on the built page -- 0 → 3 rendered data entries,
0 → 5 resolved xrefs from the Lexicon field docstrings, and members
like 'vander'/'abdul'/'bint' going from absent to present. Autodoc
against the shims was harmless, not noisy: it emitted no warning and
rendered the deprecation docstring with no members. Retargeted rather
than supplemented -- a second entry would render nothing.

migrate.rst gains the note the release-log bullet was trimmed of: the
four renames, the DeprecationWarning bridge, that the CONSTANTS
attribute names are untouched, and what the freeze prevents. That last
part is measured against the pre-freeze tree and v2.1.0, not reasoned:
an edit to a module constant landing after the first parse reached only
a freshly built Constants; landing before any parse it reached
Lexicon.default() and parse() too, but never the shared CONSTANTS,
which copies at import. Both remedies are verified warning-free.
customize.rst gets the two-sentence version, since the release note
sends readers there and it is where a 2.x caller looks for how to
change a default.

The release-log entry is rewritten to the shape every other 2.x entry
uses: a prose lead, Breaking Changes above Deprecations, and one
Deprecations bullet carrying the rename as a table instead of four
bullets each restating the bridge. Its behavior claim is measured, not
assumed -- 751 distinct names from the three differential corpora, all
seven fields through both APIs, branch against master: zero diffs, with
a planted diff proving the harness could report one.

Four claims were wrong and are corrected, not renamed:

- The config layer no longer defines "a plain Python set" -- the
  constants are frozensets. AGENTS.md says so, says what frozen means
  for a reader reaching for .add(), and warns that flipping the
  operands of the parent-set union silently unfreezes it.
- The `esq` gotcha claimed `Esq` matches only through the word set and
  that removing either membership drops a spelling. `Esq` survives the
  acronym branch's period strip, so it hits that branch too -- and the
  word membership is provably inert as shipped, since the sets'
  intersection is exactly {esq}, no SUFFIX_WORDS entry has an interior
  period, and AMBIGUOUS ∩ SUFFIX_WORDS is asserted empty. Measured to
  match: removing 'esq' from SUFFIX_WORDS changes no parse on either
  API; removing it from SUFFIX_ACRONYMS loses the family name on
  "John Smith E.S.Q.". No changed-parse COUNT is quoted -- three
  people built three 7x9 grids and got 12, 15 and 18. The count is a
  property of the grid; the zero and the direction are properties of
  the code.
- titles.py told the API reference that a title's neighbour "is a
  first name". That sentence is what the rename exists to stop saying.
- config/__init__.py said "this package is deleted in 3.0" while the
  branch makes config/particles.py the canonical home of the 2.0
  vocabulary and points public Lexicon docstrings at it. The docstring
  now claims only what is settled -- the v1 re-exports go -- and a
  plain comment records that where the DATA modules live in 3.0 is
  open, rather than settling it in published prose.

Also demotes the "keep the frozenset on the LEFT" build-safety note in
particles.py and titles.py from `#:` to `#`: it was being published as
advice to someone reading the default word list. Rechecking the render
caught that a plain comment placed INSIDE a `#:` run splits it and
autodoc silently drops everything above the split -- the first attempt
cost PARTICLES its entire docstring. The note now sits above the run,
and says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PEP 562's module `__getattr__` answers attribute ACCESS. `from x import
*` does not go through it: it reads `__all__`, or failing that the
module `__dict__`, and neither one knows the alias table exists. None
of the four alias-bearing modules had an `__all__`, so the one 1.x
import form the bridge did not cover failed in exactly the mode the
bridge exists to prevent. Measured on the branch before this commit:

    from nameparser.config.prefixes import *
      2.1.0 -> BOUND_FIRST_NAMES, NON_FIRST_NAME_PREFIXES, PREFIXES
      before -> alias_getattr
    from nameparser.config.suffixes import *
      2.1.0 -> ..., SUFFIX_NOT_ACRONYMS
      before -> ... (SUFFIX_NOT_ACRONYMS absent), alias_getattr

No DeprecationWarning, no AttributeError: just a NameError further down
at a line with nothing to do with the rename, and the bridge's own
helper bound into the caller's namespace in place of the vocabulary.
migrate.rst promised this could not happen.

Adding `__all__` routes each listed name through `__getattr__`. After:

    prefixes           -> NON_FIRST_NAME_PREFIXES, PREFIXES (2 warnings)
    bound_first_names  -> BOUND_FIRST_NAMES (1 warning)
    titles             -> GIVEN_NAME_TITLES, TITLES, FIRST_NAME_TITLES (1)
    suffixes           -> the four live constants + SUFFIX_NOT_ACRONYMS (1)

one warning per retired name, each naming its new path, and no helper
leakage anywhere. The two in-place modules list their live constants
too: a PARTIAL `__all__` would bind the retired name and drop the live
ones, trading one hole for a worse one.

The lists are in SOURCE order, not alphabetical, because `automodule
:members:` follows `__all__` where a module defines one. Written
alphabetically they silently reordered suffixes' entries in
modules.html; in source order the rendered page is byte-identical
(0-line rendered-text diff), and no retired name is documented --
autodoc's member scan never resolves them, so the build stays
warning-free and gains no duplicate entries.

`__all__` entries that are not module globals are F822 by construction,
so each carries a `# noqa: F822` (verified load-bearing: removing one
fails ruff with two F822s).

tests/v2/test_config_aliases.py pins the behavior. The expected set is
DERIVED from the module -- upper-case globals plus that module's
retired names -- rather than listed, so a constant added without an
`__all__` entry fails the test instead of needing the same person who
forgot `__all__` to remember a fixture. Both regressions were planted
in a scratch copy of the tree and confirmed to fail it: deleting
prefixes.py's `__all__`, and dropping GLUED_HONORIFICS from suffixes'.
It composes with the autouse `_cold_aliases` fixture, which serves it a
cold bridge and clears the write-back cache afterwards.

Also rewrites `_deprecated.py`'s docstring, which described a mid-branch
state ("one vocabulary at a time: the particle sets have moved, and
each remaining rename reuses this bridge as it lands"). All four have
moved and none remain. It now says which two moved module-and-all and
which two renamed in place, and why `__all__` is part of the mechanism
rather than an afterthought -- this is the first file a 3.0 sweep opens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge wrote each resolved alias back into the shim module, making
the retired name an ordinary global after the first read. The comment
sold that as repeat-suppression. It delivers something stronger and
worse: the first reader anywhere in the interpreter consumes the only
warning, and it need not be your code.

    vendored_dep.py:1   from nameparser.config.prefixes import PREFIXES
    your_code.py:1      from nameparser.config.prefixes import PREFIXES
    -> warnings seen: 1, attributed to vendored_dep.py

your_code.py is the file that has to be edited before 3.0 and was told
nothing. With the write-back gone, warnings' own __warningregistry__ --
keyed on (text, category, lineno) in the READING module's globals --
gives the semantics the comment claimed: both lines report, and a
repeat from either stays quiet. Same probe, after: 2 warnings, one per
file.

Also resolve the target before warning. A mistyped alias entry used to
advise the reader to migrate to a path that does not exist and only
then fail; now the ModuleNotFoundError/AttributeError arrives on its
own, with no warning ahead of it.

The _cold_aliases autouse fixture goes with the write-back. It existed
because the cache made every warning test order-dependent -- whoever
read first consumed the warning -- and nothing caches now. Reversed
order and one-process-per-test both pass without it.

test_old_name_warns_once_then_becomes_a_plain_global asserted the
write-back and is replaced by test_old_name_warns_once_per_read_
location, which pins both halves: one line read twice reports once, a
second line reports for itself. The filter action is load-bearing
there. pytest.warns installs "always" and the suite's own "error"
filter raises before recording; neither populates the registry, so
under either the test would record all three reads and measure
nothing. Against a scratch tree with the write-back restored it fails
`assert [114] == [114, 116]`, and against one that re-warns per read
`assert [114, 114, 116] == [114, 116]`.

Two claims in the docs went with it. "Once per name per process" is now
"once per line that reads it" in migrate.rst and release_log.rst. And
both said every 1.x name warns when read, which over-covered the two
MODULE rows: `import nameparser.config.prefixes` emits nothing at all,
since only reading a constant reaches __getattr__. migrate.rst also now
shows how to find your own uses, DeprecationWarning being hidden by
default outside __main__:

    python -W error::DeprecationWarning -c "import yourapp"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alias_getattr returns a __getattr__ typed -> Any, and mypy honors a
module __getattr__ assigned by tuple-unpacking. Assigning it plainly
therefore tells mypy that these modules answer EVERY attribute, which
switches off missing-attribute checking for the whole file. On
prefixes.py and bound_first_names.py that costs little -- they hold no
live constants. On titles.py and suffixes.py, which callers still
import from, it was a real loss. Same probe, master vs branch:

    from nameparser.config.suffixes import SUFFIX_ACRONYM   # typo
    from nameparser.config.titles   import TITLE            # typo

    master (18b0e49): 2 errors, with spelling suggestions
    before this commit: Success: no issues found

The same bogus name against nameparser.config.particles, which has no
__getattr__, errored on both, so the probe discriminates. nameparser
ships py.typed, so this reached downstream callers.

Hiding the assignment in the else of an `if TYPE_CHECKING` guard that
declares the retired names restores it. All four alias-bearing modules
get the split, not just the two with live constants: the retired names
are still a supported import path, and typing one Any hands a caller
who is on that path an unchecked value -- silently, in THEIR code.
After the split every typo in the probe errors, including PREFIX and
BOUND_FIRST_NAME on the two shims, and reveal_type on all five retired
names is frozenset[str] where it was Any.

Runtime is untouched, and measured so: each retired name still resolves
is-identical to its 2.2 constant, still warns, is still in dir(), and
star imports still bind exactly the live and retired names.

The `# noqa: F822` on the four __all__ lists is now dead -- the
declarations bind the names for ruff too. Removing it is not just
tidying: with the suppression gone, deleting the TYPE_CHECKING branch
in 3.0 without deleting __all__ becomes an error instead of nothing.
Verified by `ruff check --ignore-noqa`, which reported all five F822s
before the split and none after; the ANN401 and E402 suppressions still
report there and stay.

Two follow-ons the suite found rather than I did. The retired-name scan
rejected the docstring example, which had spelled a real retired name
in the one file that serves no vocabulary -- the same trap the
stacklevel comment already documents -- so the example uses a
placeholder. And the star-import test derived "live constants" from the
name shape alone, which now also matches the imported TYPE_CHECKING; it
tests the value type as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four checks that could stop guarding without failing.

`__dir__` on the four alias-bearing modules is an OVERRIDE, so it owes
the live names as well as the retired ones, and only the retired half
was pinned. Replacing its body with `sorted(set(aliases))` -- which
takes all four `suffixes` constants and both `titles` ones out of
`dir()`, and so out of tab completion, `inspect.getmembers` and
autodoc's member scan -- left the whole suite green at 1dc1c54 (3103
passed). The new sweep states the union over `vars()` rather than over
the vocabulary alone, because two of the four modules are data-free
shims and a vocabulary filter would go vacuous on them; the non-empty
`live_seen` check is what keeps the sweep from measuring only dunders.

The frozen-constant roster used a non-recursive `glob` and `assert
checked`, a floor of one. It now uses `rglob`, so a future `config/`
subpackage is in scope the day it lands, and a floor of 13 -- twelve
distinct constants across seven modules plus `particles`' imported
`BOUND_GIVEN_NAMES`, counted once per module it appears in. This
matters more than it did: `_default_lexicon()` used to wrap every
constant in `frozenset(...)`, and #293 dropped the wraps, so nothing
else anywhere checks that the sources are still frozen. Scope stays at
`nameparser/config`, where three consumers read the constants at three
different moments; a locale pack has one consumer at one moment (the
`Lexicon(...)` in its own body, whose fields are frozen copies), so
`locales/zh.py`'s `_SURNAMES` could not desync anything even unfrozen.
The docstring's claim that the alias modules feed cached values back
into their globals was stale in the other direction -- the bridge
deliberately has no write-back -- so the roster does not in fact depend
on what ran first.

`docs/migrate.rst`'s two replacement recipes were `::` literal blocks,
which `sphinx -b doctest` never runs, leaving the page that tells a 1.x
caller what to do instead of `TITLES.add("dean")` as the one claim
about the freeze with nothing behind it. Pinned as behavior.

And the alias table and `__all__` are two hand-written lists that were
cross-checked in one direction only. An `__all__` entry with no table
row fails loudly; a table row missing from `__all__` is dropped by
`from x import *` with no warning and no AttributeError -- verbatim the
failure fc46a9b added `__all__` to eliminate. Planting a plausible
third row in `prefixes.py`'s table at 1dc1c54 reproduced it exactly:
the star import bound two of the three names, warned twice, and the
suite stayed green (3103 passed). `alias_getattr` now hangs the table
off the `__getattr__` it returns so a test can read it; building
`__all__` from the table instead was the other option and was rejected,
since `__all__` has to stay in SOURCE order for autodoc's `bysource`
member ordering and this function cannot know the live names' order.
The test also pins the module's table against this file's literal
`ALIASES`, so a row added to one and not the other cannot leave every
other assertion here blind to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every replacement below was measured before it was written, and the
measurements are in the PR thread.

`suffixes.py`'s SUFFIX_WORDS docstring used "J.u.n.i.o.r." as the
token this set does not match. True of the lookup and false of the
parse: both APIs give suffix='J.u.n.i.o.r.', and they do so because of
this very set -- `period_joined_vocab` splits an interior-period token
on its periods and calls the whole thing a suffix if any chunk is
suffix vocabulary, and "i" is the Roman numeral listed here. The
example is now "J.u.n.o.r.", which has no such chunk and genuinely
lands in the family name on both APIs, with the joined rule named so
the sentence cannot be read as the last word on a dotted token. Third
correction to this one docstring; the first two were also plausible.

The leading-particle paragraph in `particles.py` -- prose this PR
itself wrote -- stated field destinations that `Policy(name_order=
FAMILY_FIRST)`, shipped in 2.1, falsifies: "de la Vega" reads family
'de', given 'la Vega' there, and "Van Johnson" reads family 'Van',
given 'Johnson'. Both destinations are now scoped to the order that
produces them, with the order-independent fact stated separately: what
membership decides either way is the ambiguity report, a leading
particle outside the set recording PARTICLE_OR_GIVEN under both orders
and one inside it recording none. `NON_GIVEN_NAME_PARTICLES`'s own
docstring and AGENTS.md carried the same claim about "de Mesnil" and
get the same treatment.

`suffixes.py`'s `__all__` comment said autodoc "does not document it
(it is not a module global, so autodoc's getattr-free member scan never
sees it)". The scan walks dir() and calls safe_getattr, so it does see
it: an html build resolves both retired names and emits real
DeprecationWarnings, invisible in the "0 warnings" line because Sphinx
warnings and Python warnings are different channels. The conclusion
holds for another reason -- ModuleAnalyzer finds no assignment
statement, autodoc computes is_attr=False, and at module level such a
member matches no object type at all. The source-order claim in the
same block is correct and load-bearing, and is kept.

`titles.py` said the GIVEN_NAME_TITLES-subset-of-TITLES relation is
re-checked by Lexicon, so stripping the assert under -O is covered.
It is not: Lexicon deliberately does not validate that pair (its own
"NOT validated" comment and AGENTS.md both say so), and
Lexicon(titles=frozenset({"sir"}), given_name_titles=frozenset(
{"dame"})) is accepted. Under -O the relation is unguarded, and now
says so. suffixes.py's identically worded sentence is true -- both its
relations raise ValueError -- and is untouched.

`_lexicon.py` and `test_contracts.py` both said a mutated module set
"never reached the default Lexicon". True warm-cache only: measured
against the pre-freeze tree, `TITLES.add("dean")` before the first
parse gives title='Dean' from `parse()` and puts "dean" in
`Lexicon.default().titles`. Both now state the branch, which is the
point -- which one you got was invisible.

`docs/migrate.rst` said the constants moved to "the same terminology
the Lexicon column uses". Four of five did; NON_GIVEN_NAME_PARTICLES
did not, its field being `particles_ambiguous`, the complement. A
reader pairing the bridge table with the field table twenty lines up
performs the exact inversion the flip warning exists to prevent, and
that warning is a hundred lines further down and never spelled the
constant. There is now a caveat on the row and the constant is named
inside the warning.

And CAPITALIZATION_EXCEPTIONS reads as covered by the freeze in both
`docs/migrate.rst` and AGENTS.md while the desync is still live for it:
measured on 2.2, an edit reaches a freshly built `Constants` and
neither the cached `Lexicon.default()` nor the shared `CONSTANTS`.
Leaving the dict mutable is a decided exemption; the prose was the
defect. Both sites now say it explicitly and give the configure-the-
object advice (both spellings verified), and the frozen test names
both dicts as justified carve-outs instead of letting its isinstance
filter drop them silently.

Correction to the review's own note while writing that carve-out:
REGEXES's mutability is NOT load-bearing for the reason AGENTS.md
gives. `CONSTANTS.regexes.parenthesis = ...` raises TypeError in 2.0,
and editing the module dict no longer changes nickname parsing
(delimiters reach the parse as Policy pairs). It stays exempt as a
compiled-pattern table that was never in #293's scope.

AGENTS.md's bound_given_names bullet still described
`_join_bound_first_name` in the present tense inside the section on
the current config layer; the function does not exist, the logic is in
`_pipeline/_group.py`, and `nameparser/parser.py` is a six-line shim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 force-pushed the claude/issue-293-vocabulary-rename branch from d7a06ba to f653b69 Compare August 9, 2026 19:10
@derek73
derek73 merged commit 5331b5a into master Aug 9, 2026
11 checks passed
@derek73
derek73 deleted the claude/issue-293-vocabulary-rename branch August 9, 2026 19:35
derek73 added a commit that referenced this pull request Aug 9, 2026
The previous sweep replaced a field-destination error with a MECHANISM
error at two of its six sites, and missed two more instances of its
own defect class.

`_types.py`'s `PARTICLE_OR_GIVEN` documents a KIND, and this kind has
two emitters: `_assign`'s lone leading particle and `_group`'s
prefix chain, when a title shifts the particle off index 0. All three
of the rewrite's claims were false for the second. Measured:

    Dr. Van Johnson  default       family='Van Johnson'
    Dr. Van Johnson  FAMILY_FIRST  family='Van Johnson'
    Dr. Van Johnson  FF_GIVEN_LAST family='Van Johnson'
    detail (all three, identical):
      'Van' was chained onto the following name piece; it is also a
      given name in other names

It WAS chained; it lands in `family` under every order, not just the
default; and its detail names no field, so "detail names it rather
than the kind" does not describe it. Worse, the rewrite added an
explicit denial of chaining ("rather than as a particle chaining onto
what follows") to a kind whose other emitter fires only when chaining
happened. The docstring now covers both shapes and says what
distinguishes them -- one was left standing alone and carries the role
assignment gave it, the other was claimed by the chain and names no
field because grouping runs before roles exist.

`_lexicon.py`'s `particles_ambiguous` said a member "stays a name
piece of its own instead of chaining onto what follows", which implies
a non-member chains. None does. `_group.py:204` is `if k == 0 or not
prefix(k): continue`, so the prefix chain skips index 0 before
membership is ever consulted, and both group into two pieces:

    de Mesnil  ->  [['de'], ['Mesnil']]     (never-given)
    van Gogh   ->  [['van'], ['Gogh']]      (ambiguous)

What produces family='de Mesnil' under the default is `post_rules`
rule 1b, a role fold AFTER assignment -- the rule #359 is about. That
claim also contradicted `config/particles.py:85-87`, the site the
issue named as the pattern to follow, leaving two rendered API
docstrings disagreeing; and it is the same claim `c647381` says it
measured false and deleted from `customize.rst`. Removed there,
planted here. It now names what membership actually decides: the
ambiguity report under either order, and the default order's fold for
a non-member (with the degenerate bare "de" that rule 1b's
middle/family guard leaves alone).

`docs/usage.rst`'s "where the pieces land follows that order instead"
covered both branches of the preceding sentence, including the
never-given one, so it asserted exactly what the PR said it did not.
It is wrong in substance too -- "de Mesnil" under FAMILY_FIRST is not
those pieces relocated, it is a fold that never fires. It now says
only that the destinations shown are the default order's and that
`name_order` is what decides them.

`docs/concepts.rst`: an inserted em-dash left "which is the right call
for the actor Van Johnson" binding to "the start of a surname" rather
than to the reading actually taken -- i.e. backwards. The chosen
reading and the right-call/wrong-one pair are adjacent again, and the
order-dependence moved to its own parenthesis instead of splitting
them.

Two sites the sweep missed, both its own defect class:

    _group.py:213  "Van Johnson" -> given     nine lines above the
                                              emitter it examined
    AGENTS.md:179  "the particle stayed the GIVEN name"

    Dr. Van Jr.  default       given='Van'
    Dr. Van Jr.  FAMILY_FIRST  family='Van'

#354 fixed the sibling at AGENTS.md:136 and this one was missed.

`docs/release_log.rst` said `SUFFIX_OR_NAME` "has always named the
part it declined". It names both -- `read as a family name rather
than a post-nominal` -- and the new particle detail names only the
part it took. The bullet now says which is which.

Not touched, and not this change's business: `customize.rst`'s "no
given name at all" after a family comma and `usage.rst:746-749`'s
"more likely reading" are pre-existing; the 2.2.0 preamble is
finalized at release time; and what FAMILY_FIRST should mean for a
never-given Latin particle is #359's open question, so nothing here
asserts an answer to it.

    pytest            3117 passed, 20 skipped, 11 xfailed
    mypy              Success: no issues found in 103 source files
    ruff              All checks passed!
    sphinx -b html    build succeeded, 0 warnings
    sphinx -b doctest 223 tests, 0 failures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Backwards-incompatible API change enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rename the vocabulary data modules to the 2.0 terminology (prefixes.py → particles.py), with a 2.x deprecation bridge

1 participant