From c491738de7f07075b606bf0021059627e2089cc3 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 20:39:15 -0700 Subject: [PATCH 1/9] Name the join in the vocabulary the constants now use The #293 rename could sweep the constant names -- a test fails on any internal .py that still spells PREFIXES -- but not the English around them. "leading token -> first-name join, mid-name -> last-name join" describes PARTICLES n BOUND_GIVEN_NAMES in the words 2.0 retired, on the one line the particle work in this milestone reads first. Measured, so the direction is not just renamed but right: abu bakr ahmed first='abu bakr' mohammed abu bakr last='abu bakr' Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6f007b58..bf9cebde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,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.` 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 → first-name join, mid-name → last-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`). +**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`). **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). From 7b97e1e7a3da95660887128fa36ff89a695b2c3b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 20:39:40 -0700 Subject: [PATCH 2/9] Give the internal-usage guard the reason it actually has The guard's stated cost -- an internal read "would also consume the once-per-process warning and leave the real caller told nothing" -- stopped being true when the bridge moved to per-read-location warnings. Measured on this tree: internal_reader.py:1 then real_caller.py:1 -> 2 warnings The guard is still right to exist; what it buys is attribution, not the caller's one warning. A library read points the caller at a deprecation inside nameparser/ they cannot act on, and kills any run under -W error::DeprecationWarning from library code. docs/release_log.rst already states the new semantics correctly, so this was the last site carrying the old ones. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bf9cebde..d3586f8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,7 +258,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **Parsing must never write into `Constants`** — parse-time derived recognition (dotted abbreviations like "Lt.Gov.", conjunction-joined titles/prefixes like "Mr. and Mrs." / "von und zu") lives in per-instance `HumanName._derived_titles` / `_derived_suffixes` / `_derived_conjunctions` / `_derived_prefixes` sets, consulted by `is_title`/`is_suffix`/`is_conjunction`/`is_prefix`/`is_rootname`, reset at the start of `parse_full_name()`, and backfilled in `__setstate__` for pre-existing pickles. Never `self.C..add()` during parsing — `self.C` is usually the shared `CONSTANTS` singleton, so a write makes parse results order-dependent and thread-unsafe (this was a real bug through 1.2.x). `ParsingDoesNotMutateConfigTests` (`tests/test_constants.py`) enforces the invariant by snapshotting the whole config around a parse; it discovers collections structurally via `Constants.__getstate__()`, so new `Constants` collections are watched automatically — nothing to register. If a new derived category is ever needed: add a `_derived_*` set in `__init__`, reset it in `parse_full_name()`, backfill it in `__setstate__`, consult it in the matching `is_*` predicate (store `lc()`-normalized values, mirroring `SetManager`), and add a leak test with a name that triggers it. -**The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Both are warning-free; mutating the shared `CONSTANTS` still works but warns. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. An internal read would also consume the once-per-process warning and leave the real caller told nothing. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. +**The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Both are warning-free; mutating the shared `CONSTANTS` still works but warns. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. What an internal read costs is attribution: the warning names the file that performed the read, so the caller is told about a deprecation inside `nameparser/` they cannot act on, and a run under `-W error::DeprecationWarning` dies in library code. It does *not* cost the caller their own warning — the bridge warns per read location, so a library read at `internal_reader.py:1` and a caller read at `real_caller.py:1` produce two warnings, not one. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. **`HumanName.C` is a property backed by `_C`, but pickles under the public key `'C'`** — `__init__`/direct assignment route through the `C` setter, which calls the shared `_validate_constants` staticmethod (also used by `__init__`) so an invalid value raises `TypeError` immediately instead of surfacing later as an unrelated `AttributeError` deep in parsing (#239). `__getstate__`/`__setstate__` deliberately translate `self._C` ↔ a `'C'` key in the pickled dict (with the usual `CONSTANTS`-singleton-becomes-`None` sentinel) rather than pickling `_C` directly, so the on-disk pickle format hasn't changed across this fix — don't "simplify" that translation away or old pickles/tests that hand-build a state dict with a `'C'` key will break. From 77e8eade0eafe93fa3b314355e9a8bd7218a1954 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 20:40:06 -0700 Subject: [PATCH 3/9] Say which esq spellings move, not that many do "SUFFIX_ACRONYMS - {esq} changes many" is unfalsifiable: every grid confirms it, which is why it survived review. Measured over 7 frames x 7 spellings, the split is total and grid-independent: 'E.S.Q.' 5/7 frames change 'Esq' 0/7 'E.S.Q' 5/7 frames change 'Esq.' 0/7 'ESQ' 0/7 'esq' 0/7 So the checkable claim is which spellings move, not how many rows do. This grid returns a fourth distinct count (10) for the three the paragraph already refuses to choose between, which is the argument for its no-count rule rather than against it. Relevant now because #342 removes an entry from this same set and will calibrate its blast radius against this paragraph. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d3586f8d..439c0e93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,7 +274,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/` has been hardened against this — it generates its baseline worker into a temp dir, strips `PYTHONPATH` from the child, and aborts unless both the version AND the resolved path check out on each side; its README's three invocation traps are the analysis behind that design, worth reading before changing the harness. **The exposure is ad-hoc comparisons you write yourself**, where the same collapse has a cause the harness cannot disarm for you: **the shell's working directory persists between tool calls**, so a two-tree comparison written as two `cd`s silently runs both halves in whichever tree it landed in. Pin absolute paths, and assert `nameparser.__file__` on both sides the way `compare.py` does. The general rule outlives any particular trap: before believing a null result, prove the harness can report a difference — a clean run and a broken harness are the same output. -**`esq` is in both `SUFFIX_ACRONYMS` and `SUFFIX_WORDS` on purpose — do not "deduplicate" it, and do not describe it as two spellings** — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. The load-bearing membership is the ACRONYM one, and it carries *both* spellings: `"Esq"` also survives `.replace(".","")` unchanged, so it is in `SUFFIX_ACRONYMS` too. The word membership is therefore inert as shipped — **provably**, not just on a sample: the intersection of the two sets is exactly `{esq}`, no `SUFFIX_WORDS` entry carries an interior period, and `suffixes.py` asserts `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS == ∅`, so for a word in both, the acronym branch fires wherever the word branch does. Measured to match: `SUFFIX_WORDS − {esq}` changes **no** parse on either API, while `SUFFIX_ACRONYMS − {esq}` changes many and loses the family name on the multi-dot form (`"John Smith E.S.Q."` → `family='E.S.Q.'`). Deliberately no changed-parse COUNT here — three people built three "7 frames × 9 spellings" grids and got three different numbers (12, 15, 18); the count is a property of the grid, the zero and the direction are properties of the code. The word membership is still not junk: it is v1 data parity, and it is what keeps `"Esq"` matching for a caller who removes `esq` from `SUFFIX_ACRONYMS` themselves (verified — after `C.suffix_acronyms.remove('esq')`, `"John Smith Esq"` still parses `suffix='Esq'`, and dropping both memberships gives `family='Esq'`). Pinned by the `suffix_acronym_multidot_spelling` case-table row. The disjointness that IS asserted is `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS`, which is a different claim: `suffix_as_written` ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose. `suffixes.py`'s `# NOT asserted:` block states the same reasoning at the code — keep the two in step. +**`esq` is in both `SUFFIX_ACRONYMS` and `SUFFIX_WORDS` on purpose — do not "deduplicate" it, and do not describe it as two spellings** — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. The load-bearing membership is the ACRONYM one, and it carries *both* spellings: `"Esq"` also survives `.replace(".","")` unchanged, so it is in `SUFFIX_ACRONYMS` too. The word membership is therefore inert as shipped — **provably**, not just on a sample: the intersection of the two sets is exactly `{esq}`, no `SUFFIX_WORDS` entry carries an interior period, and `suffixes.py` asserts `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS == ∅`, so for a word in both, the acronym branch fires wherever the word branch does. Measured to match: `SUFFIX_WORDS − {esq}` changes **no** parse on either API, while `SUFFIX_ACRONYMS − {esq}` changes exactly the multi-dot spellings — `E.S.Q.`/`E.S.Q` move, and every single-token spelling (`Esq`, `Esq.`, `ESQ`, `esq`) is untouched in every frame — losing the family name there (`"John Smith E.S.Q."` → `family='E.S.Q.'`). Deliberately no changed-parse COUNT here — four people built four grids of this shape and got four different numbers (12, 15, 18, 10); the count is a property of the grid, while the zero, the direction, and *which spellings move* are properties of the code. "Changes many" is what this line said before, and it is the shape of claim to avoid: it sounds measured, survives any grid, and tells the next person nothing about where to look. The word membership is still not junk: it is v1 data parity, and it is what keeps `"Esq"` matching for a caller who removes `esq` from `SUFFIX_ACRONYMS` themselves (verified — after `C.suffix_acronyms.remove('esq')`, `"John Smith Esq"` still parses `suffix='Esq'`, and dropping both memberships gives `family='Esq'`). Pinned by the `suffix_acronym_multidot_spelling` case-table row. The disjointness that IS asserted is `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS`, which is a different claim: `suffix_as_written` ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose. `suffixes.py`'s `# NOT asserted:` block states the same reasoning at the code — keep the two in step. **`Lexicon.given_name_titles` is deliberately unvalidated against `titles` — do not add a check** — the lookup key is the space-joined run of `Role.TITLE` tokens, built by the parse, and a conjunction inside a run is itself tagged `Role.TITLE`, so `"sir and dame"` is a matchable key whose middle word lives in `conjunctions`. A whole-entry check rejected multi-word entries; a per-word check rejected that one. No static relation over the vocabulary sets decides reachability. An unreachable entry is inert — nothing consults it and nothing misparses — so the condition being "guarded" costs the user nothing while each guard cost a working configuration. If a diagnostic is wanted, it must be non-blocking — the multi-word UserWarning in `_normset` is the shipped example; a raise remains wrong. Note the SHIPPED data cannot carry a multi-word given-name title without a spurious warning: `GIVEN_NAME_TITLES ⊆ TITLES` puts the entry in `titles` too, which is per-word warned; user-supplied v2 Lexicons are unaffected (`add(given_name_titles=...)` alone is silent). From 1981208b5522b7480ad3b75ef3a4de15d842aedf Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 20:41:08 -0700 Subject: [PATCH 4/9] Split the delimiter gotcha into what 2.0 kept and what it deleted #356 reads this bullet as v1 history sitting in a live section. Half of it is: the mechanism the sentinel existed FOR is gone. CONSTANTS.regexes.parenthesis = ... -> TypeError nickname_delimiters['brackets'] = re.compile(...) -> TypeError HumanName.parse_nicknames -> AttributeError But the other half is current 2.x guidance, so retiring the whole paragraph would delete advice that still works. Measured: maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis') Jane (Jones) Smith default -> nickname='Jones' routed -> maiden='Jones' Also drops the "three default keys" count, which the CJK delimiter packs took to eleven -- the same failure as the esq line one commit back, so the replacement says why not to write a count rather than a new one. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 439c0e93..a48bfee8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,7 +288,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **`is_suffix()`'s period-stripping is asymmetric** — it does `lc(piece).replace('.', '')` (strips *all* periods) before checking `suffix_acronyms`, but only `lc(piece)` (leading/trailing only) before checking `suffix_not_acronyms`. Code that reimplements this check instead of calling `is_suffix()` must mirror both branches or it will misclassify acronym suffixes with internal-only periods (e.g. `"M.D"` with no trailing dot) — bit `parse_nicknames()`'s `handle_match()` in PR #189. -**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — the three default keys (`quoted_word`, `double_quotes`, `parenthesis`) store the *name* of a `Constants.regexes` entry (a plain `str`), resolved via `getattr(self.C.regexes, name)` at parse time in `parse_nicknames()` — not the compiled `re.Pattern` itself. This is what lets `CONSTANTS.regexes.parenthesis = ...` keep affecting nickname/maiden parsing after construction, same as before this mechanism existed. Routing a built-in between buckets must be a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry that string sentinel over — copying `CONSTANTS.regexes['parenthesis']` directly into the new bucket instead would freeze it as a snapshot and silently stop tracking further `regexes` overrides. A key added by a caller for a *custom* delimiter is a real compiled pattern, distinguished at parse time via `isinstance(raw_pattern, re.Pattern)`. (#22) +**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — each default key (`quoted_word`, `double_quotes`, `parenthesis`, and the rest) stores its own *name* as a plain `str`, and `Constants._snapshot()` maps that key to the `(open, close)` pair the 2.0 `Policy` carries (`_SENTINEL_PAIRS` in `_config_shim.py`). Routing a built-in between buckets is still a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry the sentinel over — measured, that flips `"Jane (Jones) Smith"` from `nickname='Jones'` to `maiden='Jones'`. **2.0 note:** the two things this bullet used to justify the sentinel with are both gone, and only the v1 history above still holds. `CONSTANTS.regexes.parenthesis = ...` now raises `TypeError` (2.0 configures parsing through named `Policy` flags), so the sentinel no longer buys post-construction tracking of a `regexes` override; and a caller's *custom* key is refused by `_DelimiterManager` — its message points at `Policy(nickname_delimiters=...)` — rather than accepted as a compiled pattern. No `isinstance(raw_pattern, re.Pattern)` branch survives, and there is no `parse_nicknames()` left to hold one. Don't count the default keys in prose: this bullet said "three" until the CJK packs took it to eleven. (#22) **`TupleManager.__setattr__`/`__delattr__` guard dunder names too, not just `__getattr__`** — constructing a subscripted generic, e.g. `TupleManager[re.Pattern[str] | str]({...})` (needed so mypy sees the right value type instead of inferring one from the dict literal), makes `typing`'s `GenericAlias.__call__` set `__orig_class__` on the new instance right after `__init__` returns. Before this guard existed, `__setattr__` was a bare `dict.__setitem__` alias, so that assignment silently inserted a bogus `'__orig_class__'` entry into the dict itself, corrupting `.values()`/iteration for *every* `TupleManager`/`RegexTupleManager` instance, not just the one being constructed — this bit `nickname_delimiters`'s construction (#22) before the guard was added. Same fix shape as the `__getattr__` dunder guard above: fall back to `object.__setattr__`/`object.__delattr__` for dunder names, dict-backed storage for everything else. From a3f7c863858a1a14729dad12637dfb8cca650e43 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 20:41:37 -0700 Subject: [PATCH 5/9] Scope the freeze claim to the lists that are frozen "TITLES, PARTICLES and the rest of nameparser.config are frozen" overstates the 2.2 headline change in the file a user reads to act on it. Two public members are still mutable dicts: regexes.REGEXES dict capitalization.CAPITALIZATION_EXCEPTIONS dict The following sentence then said "those constants were renamed", naming TITLES and PARTICLES immediately before it -- TITLES was never renamed. Split into what froze and what got a new name, and name the two renames instead of gesturing at them. These .rst files aren't covered by CI, so this ships silently if 2.2 goes out without it. Co-Authored-By: Claude Opus 5 --- docs/customize.rst | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index d4815d0d..02da0424 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -29,12 +29,18 @@ above; ``particles``, ``suffix_words``, and the rest work the same way) — see :doc:`modules` for the full field list. The default word lists themselves — ``TITLES``, ``PARTICLES`` and the -rest of ``nameparser.config`` — are frozen, so a runtime addition -belongs on a :class:`~nameparser.Lexicon` as above, or on a private -``Constants`` if you are still parsing through ``HumanName``. Those -constants were renamed in 2.2 to match the field names used here; the -1.x names still import, with a ``DeprecationWarning``, until 3.0 — see -:doc:`migrate` for the mapping. +other frozensets in ``nameparser.config`` — are frozen, so a runtime +addition belongs on a :class:`~nameparser.Lexicon` as above, or on a +private ``Constants`` if you are still parsing through ``HumanName``. +The freeze covers the word lists only; ``REGEXES`` and +``CAPITALIZATION_EXCEPTIONS`` are still plain dicts. + +Some of these lists were renamed in 2.2 to match the field names used +here — ``PREFIXES`` became ``PARTICLES``, ``FIRST_NAME_TITLES`` became +``GIVEN_NAME_TITLES`` — while ``TITLES`` and the rest kept the names +they already had. Every 1.x name still imports, with a +``DeprecationWarning``, until 3.0 — see :doc:`migrate` for the full +mapping. Vocabulary entries are matched one word at a time (``given_name_titles`` excepted), so a multi-word entry like ``titles={"grand moff"}`` can From 6d1879bacf37ba4b6e70411d5b34baae6ff2f41b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 20:42:40 -0700 Subject: [PATCH 6/9] Cite the right section, and stop depending on the citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer note pointed at the migration spec's §3 for the 3.0 removal list. §3 is "The Constants shim"; the list is in §4, "Warning inventory & removal schedule". Both citations resolve to docs/superpowers/, which .gitignore:31 excludes -- so an outside reader could check neither the section number nor the claim it supports. The note now carries the claim inline (the list says "nameparser.config in its entirety" while its parenthetical names only the five shim exports), leaving the section number as corroboration rather than the only source. Leaves the "migration spec §N" convention alone in the other seven shipped modules that use it; that's a convention call, not housekeeping. Co-Authored-By: Claude Opus 5 --- nameparser/config/__init__.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index d4ce2259..9528f1b9 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -19,15 +19,20 @@ compatibility failure. """ # Maintainer note, deliberately outside the docstring: the docstring -# above no longer says "this package is deleted in 3.0", which the -# migration spec's §3 list asserts while enumerating only the shim -# names in its parenthetical. Whether the DATA modules keep this -# package as their home in 3.0 or move under the core is an open -# decision, not something to settle in a published docstring -- and it -# now has a consequence, since Lexicon's public field docs -# cross-reference nameparser.config.particles et al. Resolve it when -# 3.0 is planned; until then this docstring claims only what is -# settled, which is that the re-exports below go. +# above no longer says "this package is deleted in 3.0". The migration +# spec's removal schedule (§4, not §3 -- §3 is the shim itself) does +# say it, listing "nameparser.config in its entirety" while its own +# parenthetical enumerates only the five shim exports re-exported +# below. That gap is the whole reason for this note, so it is stated +# here rather than left to a section number: the spec lives under +# docs/superpowers/, which is gitignored and absent for anyone outside +# this checkout. Whether the DATA modules keep this package as their +# home in 3.0 or move under the core is an open decision, not something +# to settle in a published docstring -- and it now has a consequence, +# since Lexicon's public field docs cross-reference +# nameparser.config.particles et al. Resolve it when 3.0 is planned; +# until then this docstring claims only what is settled, which is that +# the re-exports below go. from nameparser._config_shim import CONSTANTS as CONSTANTS from nameparser._config_shim import Constants as Constants from nameparser._config_shim import RegexTupleManager as RegexTupleManager From b50a401fc95bc1a13420276f34337391f55d423e Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 22:07:04 -0700 Subject: [PATCH 7/9] Name all five renames, and say where a dict edit lands Two review findings on the previous commit, both in the paragraph it rewrote. "TITLES and the rest kept the names they already had" is false for three of the five renames -- NON_FIRST_NAME_PREFIXES, BOUND_FIRST_NAMES and SUFFIX_NOT_ACRONYMS all moved. Naming two and generalizing over the rest was worse than the vague sentence it replaced, which at least claimed nothing specific. All five are named now, so "names outside that list are unchanged" is true. "REGEXES and CAPITALIZATION_EXCEPTIONS are still plain dicts" sat in a paragraph about why runtime mutation does not work and read as an exemption. Measured, mutating the module dict: v1 shared CONSTANTS unchanged v1 fresh Constants() CHANGED v2 Lexicon.default() unchanged That is not a no-op, it is the inconsistent reach the freeze was introduced to remove -- worth stating rather than implying either way. Co-Authored-By: Claude Opus 5 --- docs/customize.rst | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index 02da0424..6b4e08d7 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -32,15 +32,23 @@ The default word lists themselves — ``TITLES``, ``PARTICLES`` and the other frozensets in ``nameparser.config`` — are frozen, so a runtime addition belongs on a :class:`~nameparser.Lexicon` as above, or on a private ``Constants`` if you are still parsing through ``HumanName``. -The freeze covers the word lists only; ``REGEXES`` and -``CAPITALIZATION_EXCEPTIONS`` are still plain dicts. - -Some of these lists were renamed in 2.2 to match the field names used -here — ``PREFIXES`` became ``PARTICLES``, ``FIRST_NAME_TITLES`` became -``GIVEN_NAME_TITLES`` — while ``TITLES`` and the rest kept the names -they already had. Every 1.x name still imports, with a -``DeprecationWarning``, until 3.0 — see :doc:`migrate` for the full -mapping. +``REGEXES`` and ``CAPITALIZATION_EXCEPTIONS`` are the two members the +freeze does not cover — they are still plain dicts. Editing one at +runtime is not a supported override, and it is not a clean no-op +either: the edit reaches a freshly built ``Constants``, while the +shared ``CONSTANTS`` (copied at import) and the cached +:meth:`~nameparser.Lexicon.default` never see it. That is the same +inconsistent reach the freeze removed for the word lists, so these +overrides belong on a config object too. + +Five of these lists were renamed in 2.2 to match the field names used +here: ``PREFIXES``, ``NON_FIRST_NAME_PREFIXES``, ``BOUND_FIRST_NAMES``, +``FIRST_NAME_TITLES`` and ``SUFFIX_NOT_ACRONYMS`` became ``PARTICLES``, +``NON_GIVEN_NAME_PARTICLES``, ``BOUND_GIVEN_NAMES``, +``GIVEN_NAME_TITLES`` and ``SUFFIX_WORDS``, and two modules moved with +them. Names outside that list, ``TITLES`` among them, are unchanged. +Every 1.x name still imports, with a ``DeprecationWarning``, until 3.0 +— see :doc:`migrate` for the full mapping. Vocabulary entries are matched one word at a time (``given_name_titles`` excepted), so a multi-word entry like ``titles={"grand moff"}`` can From 4794213b766f7335120a00bda42c26f1db07286b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 22:07:34 -0700 Subject: [PATCH 8/9] Fix three claims this branch got wrong while fixing others Review findings on the three AGENTS.md commits below. Layer attribution (the #293 bullet): "the bridge warns per read location" credits the bridge with the warnings module's behavior. A control -- two files issuing an identical plain warnings.warn -- emits the same two warnings under -W once, -W default and -W module. Per- location suppression is __warningregistry__; what the bridge actually contributes is not caching the resolved value, which config/_deprecated.py already explains and this file now matches. Delimiter provenance: the eight added keys came from one commit, 7ee6e3a (#273), not from locale packs, and five of the eight are Western typographic pairs rather than CJK. Replacing a stale count with a wrong attribution is the same failure in a new coat. parse_nicknames: gone as a method, still live in _facade.py's _V1_HOOKS, where overriding it in a v1 subclass warns (#280). "There is no parse_nicknames() left" invited deleting a diagnostic. Also reframes the esq single-token claim as the proof it is -- each spelling normalizes into the word branch regardless of the acronym set -- rather than a universal quantified over sampled frames, which is the support shape this file warns about two sections earlier. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a48bfee8..09abd30b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,7 +258,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **Parsing must never write into `Constants`** — parse-time derived recognition (dotted abbreviations like "Lt.Gov.", conjunction-joined titles/prefixes like "Mr. and Mrs." / "von und zu") lives in per-instance `HumanName._derived_titles` / `_derived_suffixes` / `_derived_conjunctions` / `_derived_prefixes` sets, consulted by `is_title`/`is_suffix`/`is_conjunction`/`is_prefix`/`is_rootname`, reset at the start of `parse_full_name()`, and backfilled in `__setstate__` for pre-existing pickles. Never `self.C..add()` during parsing — `self.C` is usually the shared `CONSTANTS` singleton, so a write makes parse results order-dependent and thread-unsafe (this was a real bug through 1.2.x). `ParsingDoesNotMutateConfigTests` (`tests/test_constants.py`) enforces the invariant by snapshotting the whole config around a parse; it discovers collections structurally via `Constants.__getstate__()`, so new `Constants` collections are watched automatically — nothing to register. If a new derived category is ever needed: add a `_derived_*` set in `__init__`, reset it in `parse_full_name()`, backfill it in `__setstate__`, consult it in the matching `is_*` predicate (store `lc()`-normalized values, mirroring `SetManager`), and add a leak test with a name that triggers it. -**The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Both are warning-free; mutating the shared `CONSTANTS` still works but warns. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. What an internal read costs is attribution: the warning names the file that performed the read, so the caller is told about a deprecation inside `nameparser/` they cannot act on, and a run under `-W error::DeprecationWarning` dies in library code. It does *not* cost the caller their own warning — the bridge warns per read location, so a library read at `internal_reader.py:1` and a caller read at `real_caller.py:1` produce two warnings, not one. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. +**The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Both are warning-free; mutating the shared `CONSTANTS` still works but warns. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. What an internal read costs is attribution: the warning names the file that performed the read, so the caller is told about a deprecation inside `nameparser/` they cannot act on, and a run under `-W error::DeprecationWarning` dies in library code. It does *not* cost the caller their own warning, and the reason is the warnings module rather than anything this bridge does: `__warningregistry__` lives in the READING module's globals, so repeats are suppressed per LOCATION, and a library read at `internal_reader.py:1` plus a caller read at `real_caller.py:1` produce two warnings — the same two that a plain `warnings.warn` from two files produces, verified against that control. What the bridge contributes is the *absence* of a write-back: `config/_deprecated.py` keeps serving retired names through `__getattr__` for the life of the process instead of caching the resolved value into module globals, which would silence every reader after the first — and the first is whoever imported earliest, routinely a dependency whose author is not the person who has to edit anything. Don't "optimize" that lookup. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. **`HumanName.C` is a property backed by `_C`, but pickles under the public key `'C'`** — `__init__`/direct assignment route through the `C` setter, which calls the shared `_validate_constants` staticmethod (also used by `__init__`) so an invalid value raises `TypeError` immediately instead of surfacing later as an unrelated `AttributeError` deep in parsing (#239). `__getstate__`/`__setstate__` deliberately translate `self._C` ↔ a `'C'` key in the pickled dict (with the usual `CONSTANTS`-singleton-becomes-`None` sentinel) rather than pickling `_C` directly, so the on-disk pickle format hasn't changed across this fix — don't "simplify" that translation away or old pickles/tests that hand-build a state dict with a `'C'` key will break. @@ -274,7 +274,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/` has been hardened against this — it generates its baseline worker into a temp dir, strips `PYTHONPATH` from the child, and aborts unless both the version AND the resolved path check out on each side; its README's three invocation traps are the analysis behind that design, worth reading before changing the harness. **The exposure is ad-hoc comparisons you write yourself**, where the same collapse has a cause the harness cannot disarm for you: **the shell's working directory persists between tool calls**, so a two-tree comparison written as two `cd`s silently runs both halves in whichever tree it landed in. Pin absolute paths, and assert `nameparser.__file__` on both sides the way `compare.py` does. The general rule outlives any particular trap: before believing a null result, prove the harness can report a difference — a clean run and a broken harness are the same output. -**`esq` is in both `SUFFIX_ACRONYMS` and `SUFFIX_WORDS` on purpose — do not "deduplicate" it, and do not describe it as two spellings** — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. The load-bearing membership is the ACRONYM one, and it carries *both* spellings: `"Esq"` also survives `.replace(".","")` unchanged, so it is in `SUFFIX_ACRONYMS` too. The word membership is therefore inert as shipped — **provably**, not just on a sample: the intersection of the two sets is exactly `{esq}`, no `SUFFIX_WORDS` entry carries an interior period, and `suffixes.py` asserts `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS == ∅`, so for a word in both, the acronym branch fires wherever the word branch does. Measured to match: `SUFFIX_WORDS − {esq}` changes **no** parse on either API, while `SUFFIX_ACRONYMS − {esq}` changes exactly the multi-dot spellings — `E.S.Q.`/`E.S.Q` move, and every single-token spelling (`Esq`, `Esq.`, `ESQ`, `esq`) is untouched in every frame — losing the family name there (`"John Smith E.S.Q."` → `family='E.S.Q.'`). Deliberately no changed-parse COUNT here — four people built four grids of this shape and got four different numbers (12, 15, 18, 10); the count is a property of the grid, while the zero, the direction, and *which spellings move* are properties of the code. "Changes many" is what this line said before, and it is the shape of claim to avoid: it sounds measured, survives any grid, and tells the next person nothing about where to look. The word membership is still not junk: it is v1 data parity, and it is what keeps `"Esq"` matching for a caller who removes `esq` from `SUFFIX_ACRONYMS` themselves (verified — after `C.suffix_acronyms.remove('esq')`, `"John Smith Esq"` still parses `suffix='Esq'`, and dropping both memberships gives `family='Esq'`). Pinned by the `suffix_acronym_multidot_spelling` case-table row. The disjointness that IS asserted is `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS`, which is a different claim: `suffix_as_written` ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose. `suffixes.py`'s `# NOT asserted:` block states the same reasoning at the code — keep the two in step. +**`esq` is in both `SUFFIX_ACRONYMS` and `SUFFIX_WORDS` on purpose — do not "deduplicate" it, and do not describe it as two spellings** — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. The load-bearing membership is the ACRONYM one, and it carries *both* spellings: `"Esq"` also survives `.replace(".","")` unchanged, so it is in `SUFFIX_ACRONYMS` too. The word membership is therefore inert as shipped — **provably**, not just on a sample: the intersection of the two sets is exactly `{esq}`, no `SUFFIX_WORDS` entry carries an interior period, and `suffixes.py` asserts `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS == ∅`, so for a word in both, the acronym branch fires wherever the word branch does. Measured to match: `SUFFIX_WORDS − {esq}` changes **no** parse on either API, while `SUFFIX_ACRONYMS − {esq}` changes exactly the multi-dot spellings — `E.S.Q.`/`E.S.Q` move, losing the family name there (`"John Smith E.S.Q."` → `family='E.S.Q.'`), while every single-token spelling (`Esq`, `Esq.`, `ESQ`, `esq`) is untouched — and that half is a PROOF, not a sample, from the algebra just above: each normalizes to `esq` under the word test's edge-period strip, so `SUFFIX_WORDS` catches it whatever the acronym set holds. A 26-frame sweep found no exception, but the sweep only corroborates; the argument is what makes it safe to rely on. Deliberately no changed-parse COUNT here — four people built four grids of this shape and got four different numbers (12, 15, 18, 10); the count is a property of the grid, while the zero, the direction, and *which spellings move* are properties of the code. "Changes many" is what this line said before, and it is the shape of claim to avoid: it sounds measured, survives any grid, and tells the next person nothing about where to look. The word membership is still not junk: it is v1 data parity, and it is what keeps `"Esq"` matching for a caller who removes `esq` from `SUFFIX_ACRONYMS` themselves (verified — after `C.suffix_acronyms.remove('esq')`, `"John Smith Esq"` still parses `suffix='Esq'`, and dropping both memberships gives `family='Esq'`). Pinned by the `suffix_acronym_multidot_spelling` case-table row. The disjointness that IS asserted is `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS`, which is a different claim: `suffix_as_written` ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose. `suffixes.py`'s `# NOT asserted:` block states the same reasoning at the code — keep the two in step. **`Lexicon.given_name_titles` is deliberately unvalidated against `titles` — do not add a check** — the lookup key is the space-joined run of `Role.TITLE` tokens, built by the parse, and a conjunction inside a run is itself tagged `Role.TITLE`, so `"sir and dame"` is a matchable key whose middle word lives in `conjunctions`. A whole-entry check rejected multi-word entries; a per-word check rejected that one. No static relation over the vocabulary sets decides reachability. An unreachable entry is inert — nothing consults it and nothing misparses — so the condition being "guarded" costs the user nothing while each guard cost a working configuration. If a diagnostic is wanted, it must be non-blocking — the multi-word UserWarning in `_normset` is the shipped example; a raise remains wrong. Note the SHIPPED data cannot carry a multi-word given-name title without a spurious warning: `GIVEN_NAME_TITLES ⊆ TITLES` puts the entry in `titles` too, which is per-word warned; user-supplied v2 Lexicons are unaffected (`add(given_name_titles=...)` alone is silent). @@ -288,7 +288,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **`is_suffix()`'s period-stripping is asymmetric** — it does `lc(piece).replace('.', '')` (strips *all* periods) before checking `suffix_acronyms`, but only `lc(piece)` (leading/trailing only) before checking `suffix_not_acronyms`. Code that reimplements this check instead of calling `is_suffix()` must mirror both branches or it will misclassify acronym suffixes with internal-only periods (e.g. `"M.D"` with no trailing dot) — bit `parse_nicknames()`'s `handle_match()` in PR #189. -**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — each default key (`quoted_word`, `double_quotes`, `parenthesis`, and the rest) stores its own *name* as a plain `str`, and `Constants._snapshot()` maps that key to the `(open, close)` pair the 2.0 `Policy` carries (`_SENTINEL_PAIRS` in `_config_shim.py`). Routing a built-in between buckets is still a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry the sentinel over — measured, that flips `"Jane (Jones) Smith"` from `nickname='Jones'` to `maiden='Jones'`. **2.0 note:** the two things this bullet used to justify the sentinel with are both gone, and only the v1 history above still holds. `CONSTANTS.regexes.parenthesis = ...` now raises `TypeError` (2.0 configures parsing through named `Policy` flags), so the sentinel no longer buys post-construction tracking of a `regexes` override; and a caller's *custom* key is refused by `_DelimiterManager` — its message points at `Policy(nickname_delimiters=...)` — rather than accepted as a compiled pattern. No `isinstance(raw_pattern, re.Pattern)` branch survives, and there is no `parse_nicknames()` left to hold one. Don't count the default keys in prose: this bullet said "three" until the CJK packs took it to eleven. (#22) +**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — each default key (`quoted_word`, `double_quotes`, `parenthesis`, and the rest) stores its own *name* as a plain `str`, and `Constants._snapshot()` maps that key to the `(open, close)` pair the 2.0 `Policy` carries (`_SENTINEL_PAIRS` in `_config_shim.py`). Routing a built-in between buckets is still a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry the sentinel over — measured, that flips `"Jane (Jones) Smith"` from `nickname='Jones'` to `maiden='Jones'`. **2.0 note:** the two things this bullet used to justify the sentinel with are both gone, and only the v1 history above still holds. `CONSTANTS.regexes.parenthesis = ...` now raises `TypeError` (2.0 configures parsing through named `Policy` flags), so the sentinel no longer buys post-construction tracking of a `regexes` override; and a caller's *custom* key is refused by `_DelimiterManager` — its message points at `Policy(nickname_delimiters=...)` — rather than accepted as a compiled pattern. No `isinstance(raw_pattern, re.Pattern)` branch survives, and `HumanName.parse_nicknames` is gone as a method — but do NOT sweep the name itself as dead: it is still live in `_facade.py`'s `_V1_HOOKS`, where a v1 subclass that overrides it earns the once-per-subclass hook warning (#280). Don't count the default keys in prose either: this bullet said "three" until #273 added eight typographic pairs in one commit — five Western (`smart_double_quotes`, `low_high_quotes`, `right_double_quotes`, `guillemets`, `reversed_guillemets`) and three CJK — taking it to eleven. (#22) **`TupleManager.__setattr__`/`__delattr__` guard dunder names too, not just `__getattr__`** — constructing a subscripted generic, e.g. `TupleManager[re.Pattern[str] | str]({...})` (needed so mypy sees the right value type instead of inferring one from the dict literal), makes `typing`'s `GenericAlias.__call__` set `__orig_class__` on the new instance right after `__init__` returns. Before this guard existed, `__setattr__` was a bare `dict.__setitem__` alias, so that assignment silently inserted a bogus `'__orig_class__'` entry into the dict itself, corrupting `.values()`/iteration for *every* `TupleManager`/`RegexTupleManager` instance, not just the one being constructed — this bit `nickname_delimiters`'s construction (#22) before the guard was added. Same fix shape as the `__getattr__` dunder guard above: fall back to `object.__setattr__`/`object.__delattr__` for dunder names, dict-backed storage for everything else. From 3038cd423bd4512ff0596a6989c8a02a7b950a90 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 13 Aug 2026 22:08:13 -0700 Subject: [PATCH 9/9] Point SUFFIX_ACRONYMS_AMBIGUOUS at the readers it has Both comments on this set said it was consulted only by parse_nicknames(), which has not existed as a method since the M11 swap. Same staleness class as the rest of this branch, but in a shipped module rather than in docs -- surfaced by the review of the delimiter bullet, not by #356's list. The 2.x readers: _pipeline/_extract.py:60 the delimited-content escape _pipeline/_vocab.py:127 the period gate for bare recognition _pipeline/_classify.py:47 the vocab:suffix-ambiguous tag Co-Authored-By: Claude Opus 5 --- nameparser/config/suffixes.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 1e717ce0..f71bdb16 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -169,11 +169,14 @@ """ SUFFIX_ACRONYMS_AMBIGUOUS = frozenset({ # Suffix acronyms that also commonly work as given-name nicknames on - # their own (e.g. "Ed", "JD"). Read only by HumanName.parse_nicknames() - # when deciding whether parenthesized/quoted content is a nickname or a - # suffix -- content matching one of these stays a nickname rather than - # being reclassified as a suffix, since that's the more common reading - # in ambiguous, delimiter-only context. + # their own (e.g. "Ed", "JD"). Two readers in 2.x, not the single v1 + # one this comment used to name: _extract._suffix_shaped, deciding + # whether parenthesized/quoted content is a nickname or a suffix + # (content matching one of these stays a nickname, the more common + # reading in ambiguous, delimiter-only context), and _vocab's + # suffix_as_written, which excludes the ambiguous subset from plain + # acronym membership so the period gate below is not dead code. + # _classify also tags membership as "vocab:suffix-ambiguous". # # When adding a new entry to SUFFIX_ACRONYMS, also add it here only if # the exact letter sequence could plausibly be someone's name on its @@ -192,7 +195,8 @@ Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a common given-name nickname. Not a partition of SUFFIX_ACRONYMS -- a small, -standalone exception list consulted only by parse_nicknames(). +standalone exception list, read by the delimited-content escape in +``_pipeline/_extract.py`` and by ``_pipeline/_vocab.py``'s period gate. """ SUFFIX_ACRONYMS = frozenset({