Skip to content

nameparser 2.0 implementation#288

Draft
derek73 wants to merge 118 commits into
masterfrom
v2/core-foundation
Draft

nameparser 2.0 implementation#288
derek73 wants to merge 118 commits into
masterfrom
v2/core-foundation

Conversation

@derek73

@derek73 derek73 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Implementation branch for the 2.0 design — see the RFC in #285 and the umbrella issue #284 for the design discussion and feedback questions. Implementation learnings that change the design will land as amendment commits on #285, per the plan described there.

⚠️ Merges only at 2.0.0. This branch raises the Python floor to >=3.11 (#257) and drops the typing_extensions conditional dependency (nameparser now has zero runtime dependencies). Master must remain able to cut 1.4.x patch releases for Python 3.10 users, so this PR stays a draft until the 2.0.0 release.

Progress

  • Core data & config modelSpan, Role, Token, Ambiguity/AmbiguityKind, ParsedName; Lexicon, Policy/PolicyPatch/apply_patch, Locale. Frozen, slotted, hashable, picklable dataclasses with eager fail-loud validation; ~110 dedicated tests under tests/v2/.
  • Rendering (render() / initials() / capitalized() / __str__)
  • Parse pipeline + Parser / parse() / parser_for() / matches() + shared case table
  • v1 HumanName facade + CONSTANTS shim (existing test corpus as regression harness)
  • Locale packs (ru, tr_az) + differential-parse harness

Notes on what's here so far

🤖 Generated with Claude Code

derek73 and others added 30 commits July 12, 2026 12:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v2 core design uses enum.StrEnum (3.11+). Full #257 (dropping
typing_extensions, CI matrix) remains tracked on the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Validate that all replace() field values are str (user error if None)
- Append missing-role synthetics in canonical Role order, not kwargs order
- Unquote return type annotation (postponed annotations in effect)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Casefolded seven-component tuple in canonical Role order for dedup,
dict keys, and sorting. Semantic layer comparison; __eq__ remains strict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds tests/v2/test_layering.py to mechanically enforce the conventions
doc's import layering and the public v2 export surface. Appends the v2
core types (Span, Role, Token, Ambiguity, AmbiguityKind, ParsedName,
Lexicon, Policy, PolicyPatch, PatronymicRule, UNSET, GIVEN_FIRST,
FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, Locale) to nameparser/__init__.py
alongside the existing v1 HumanName export.

Turns on component-flag mypy strictness for the four v2 modules and
check_untyped_defs for tests/v2, then fixes the resulting test-only
type mismatches by using the precise constructed types (Span(...),
frozenset({...}), tuple-of-pairs) where the literal type didn't matter
to the test, and adding narrow # type: ignore[arg-type] comments only
where a test deliberately exercises runtime coercion/validation of an
intentionally mismatched static type (e.g. Token span/Ambiguity kind
coercion tests, PatronymicRule string coercion, dict aliasing test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pickle.dumps(Lexicon.default()) raised TypeError because the default
slots-dataclass pickle path serializes the _cap_map MappingProxyType.
Ship every other slot and rebuild the proxy from the canonical
capitalization_exceptions tuple on load. Parser (Plan 3) is picklable
by construction per the core spec, and a Parser holds a Lexicon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requires-python is >=3.11 on this branch, so the 3.10 job can no
longer install the package (uv sync either fails or silently
substitutes a managed 3.11). The rest of #257 (typing_extensions,
classifiers) stays tracked on the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With requires-python at >=3.11, Self imports directly from typing and
the conditional dependency can never activate; ruff (UP035/UP036)
flags the dead version block once the floor is raised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v1 suite is fully annotated (since #250 put tests/ under mypy), so
pyproject carries no per-file ANN ignores; the new tests/v2 modules
must be annotated too or 'ruff check' fails in CI. Also drops an
unused import ruff flagged (F401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
derek73 and others added 30 commits July 16, 2026 00:49
/simplify (all four reviewers converged on the first): the trickiest
membership expression in the vocabulary layer -- unambiguous acronym OR
suffix word, ambiguous acronym only with periods -- was spelled in full
in both classify and is_suffix_strict, aligned only by comments. Drift
would make the comma-structure decision disagree with piece
classification for the same token. Now _vocab.suffix_as_written is the
single source; classify and the strict/lenient predicates compose it
(and normalize each token once instead of twice on the lenient path).

The three-comma set was likewise defined in tokenize and extract under
different names with no sync note; it moves to _state.COMMA_CHARS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/simplify: the piece predicates were typed for group's internal
mutable lists, so every cross-stage call paid a triple
list/set/tuple conversion (including O(n) token-tuple copies inside
assign's peel loops and group's maiden scan). They now take
Sequence/Set and the call-site churn is gone.

Also: the leading-title peel was spelled twice (assign's main path
and the FAMILY_COMMA branch, the latter with a dead 'not given_done'
guard) -- extracted as _peel_leading_titles; the trailing all-suffix
segment loops collapse into one loop over the structure's tail; the
five hand-rolled pieces/ptags parallel-slice merges in _group_segment
become one merge() closure that keeps the arrays in lockstep;
post_rules' 'others' list was only ever tested for truthiness (any());
the patronymic comma gate is a named rotations_apply boolean instead
of falsifying the policy value; and the module docstring records the
GIVEN_FIRST positional assumption the rules inherit from v1 (#270).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/simplify efficiency findings: with the default strip flags every
non-space character paid two failing regex calls in _ignorable; both
strip classes are entirely non-ASCII, so an isascii() fast path skips
them for virtually all real input. Span is a NamedTuple and already
sorts as a tuple, so the tuple(...) sort keys in tokenize/extract were
pure allocation. The case runner's seven-field list now derives from
Role, whose declaration order the type declares canonical -- a new
Role member fails the runner instead of being silently skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…proxy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…a shared parser cache

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fix_delimiter parity)

Migration plan M5. v1's suffix_delimiter fired at three parse sites
(expand_suffix_delimiter, parser.py:1318/1332/1409): post-comma
suffix-segment DETECTION and tail-segment CONSUMPTION. The v2 wiring:

- segment: a delimiter-core token (configured delimiter, whitespace
  stripped) is transparent in the all-suffix tests, and a token that a
  no-whitespace core splits into all-suffix parts counts as a suffix
  (splits_into_suffixes) -- covers the SUFFIX_COMMA determination and
  suppresses the spurious COMMA_STRUCTURE flag v1 never emitted
- group: tail segments (FAMILY_COMMA >=2, SUFFIX_COMMA >=1) drop
  delimiter-core tokens via the same structural mechanism as maiden
  markers; assemble already omits dropped tokens
- the 'not yet consumed' Policy warning is gone: the field is consumed

Five case rows pinned live against v1.4 (2026-07-16); the one
intentional divergence -- a no-whitespace core keeps the token whole
(anti-#100), rendering 'RN/CRNA' where v1 rendered 'RN, CRNA' -- is
classified fix(suffix-delimiter-rendering) for the release log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y-tracked parsing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity equality

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iliation follows)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bucket A)

empty_attribute_default was removed in 2.0 (#255; nameparser/_config_shim.py's
Constants.__setattr__ now raises AttributeError on assignment, and there is no
stored default to read either). conftest.py's autouse fixture used to
parametrize every test over ['', None] to reproduce tests.py's old dual-path
regression check, plus snapshot/restore CONSTANTS around each test. The dual
path no longer exists, so only the isolation half remains, un-parametrized.
Also drops "regexes" from the restored collection attrs: it's a read-only
_RegexesProxy in 2.0, so it can neither be mutated by a test nor reassigned
by this fixture's restore loop.

tests/base.py's `m()` helper had the same dependency (falling back to
hn.C.empty_attribute_default, which no longer exists); empty attributes are
always '' now, so it falls back to '' directly.

pyproject.toml/conftest.py's TEMPORARY exclude lists are re-synced to the
current not-yet-reconciled file set for this batch (test_python_api.py and
test_constants.py go back on the list -- see the batch report for why).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file used ast to recover Sphinx "attribute docstring" doctests (bare
string literals following an attribute assignment) from the Constants class
in nameparser.config, since that convention is invisible to Python's own
doctest.DocTestFinder. After the M11 swap, nameparser/config/__init__.py is
a pure re-export shim -- it no longer defines a Constants class at all (the
real one lives in nameparser/_config_shim.py), so the module-level AST walk
raised ValueError on import: `(class_node,) = (... 0 matches ...)`.

Even pointed at the right module, there is nothing left to discover: the
shim's Constants fields are bare type annotations (`patronymic_name_order:
bool`), not `attr = default` followed by a prose+doctest string literal, so
the mechanism this file existed to protect no longer has any doctest
examples to run. Deleting rather than porting, per bucket A.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M12 reconciliation surfaced this: dropping empty buckets renumbered
the segments, so in 'Doe,, Jr.' the tail suffix landed in the given
segment and the lone-post-comma-title heuristic claimed it
(title='Jr.'). v1's mechanics, pinned live: collapse_whitespace strips
exactly ONE trailing comma as cosmetic; every other empty part keeps
its position -- an empty given segment ('Doe,,'), a failed
suffix-comma detection ('John Smith,, MD'), silent consumption of
empty tails ('Doe, John,, Jr.'). Five case rows pin the matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r, matches message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
158 dual-run cases -> 67 tests (the empty_attribute_default dual-run
parametrization died in the previous batch commit; per-run count 79 -> 67).

Bucket A (deleted machinery):
- test_unpickle_legacy_state_without_derived_sets: pinned __setstate__
  backfill of the v1 per-parse _derived_* sets and the is_title predicate,
  both gone with the v1 parser.
- the six is_prefix/is_conjunction/is_suffix list-fallthrough tests: the v1
  predicate methods are not part of the facade (#280 hook list).
- test_get_full_name_attribute_references_internal_lists: pinned the v1
  full_name getter re-rendering from the live *_list state; 2.0 deliberately
  returns the stored input string (spec section 2 mutation semantics, pinned
  in tests/v2/test_facade.py::test_field_assignment_str_list_none) and the
  *_list attributes are snapshots without setters (spec section 2 exc. 1).

Bucket B (scheduled removals now raising / silently changed):
- #245 bytes: both HumanName(bytes) and the full_name setter now
  pytest.raises(TypeError, match="decode").
- #223 ==/hash: rewritten to assert the identity semantics (distinct
  instances unequal, strings never match, hash == object default, no
  warnings), with matches()/comparison_key() shown as the replacements.
- #258 slices/__setitem__: slice access raises TypeError naming the issue;
  item assignment raises TypeError for every key; str-key reads stay silent.
- #255: given_names empty expectation is '' outright.
- Constants(regexes=...) raises TypeError pointing at Policy (deliberate
  2.0 divergence, migration spec section 3 uniform rule).

3 of 67 tests still fail on one confirmed pipeline bug (SUFFIX_COMMA
detection tests all post-comma segments where v1 tested only parts[1];
repro 'Dr. John P. Doe-Ray, CLU, CFP, LUTC'), so the file stays in
collect_ignore_glob with a pointer comment; it is mypy-clean and leaves
the mypy exclude. test_constants.py stays ignored, blocked on a wider
shim-regression inventory (see conftest comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ulary

M12 reconciliation surfaced two more Plan-3 misports, both pinned
live against v1.4 (2026-07-16):

- the suffix-comma structure is decided by parts[1] ALONE
  (parser.py:1318); v2 wrongly required every post-first segment to
  be suffix-shaped, so one unrecognized credential ('LUTC') collapsed
  the whole parse into FAMILY_COMMA. Non-suffix tails now ride along
  as suffixes with the COMMA_STRUCTURE flag (v1 consumed them
  silently; the flag is the v2 value-add).
- v1's period-joined derivation (parse_pieces): a token with an
  interior period whose chunks include ANY title is a title
  ('Lt.Gov.', 'Mr.Smith'), else ANY suffix chunk makes it a suffix
  ('JD.CPA'). Title wins. Ported into classify with the ANY rule
  kept deliberately.

Six case rows pin the matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
 + operators)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also lifts test_python_api.py's collect_ignore_glob entry: its 3 held
tests pass after the suffix-comma and period-joined-vocabulary pipeline
fixes (7bf53f3), making the file fully green (67/67).

test_constants.py: 240 dual-run cases -> 94 tests (the dual-run
parametrization died with #255; per-run 120 -> 94).

Bucket A (deleted machinery):
- the suffixes_prefixes_titles cached-union property block (~13 tests of
  cache priming/invalidation incl. the timeit performance guard and the
  is_rootname consistency pair); change tracking is the shim generation
  counter, covered in tests/v2/test_config_shim.py. Two contracts
  survived and are re-pinned on parsing surface: post-unpickle mutations
  are honored, and wholesale manager replacement re-wires tracking.
- .elements raw-set accessor uses rewritten to set(...) (operators also
  return plain set now); _repr_collection_attrs/_repr_scalar_attrs class
  internals replaced by test-owned name lists.
- __setstate__ missing-field validation test (the shim deliberately
  accepts partial state to load v1.4 blobs).
- the legacy-unpickle warn-once pair (#279's warning machinery is gone).

Bucket B (scheduled removals now raising):
- #255 empty_attribute_default: assignment raises AttributeError; the
  six None-mode behavior tests collapse to one removal test (scalar
  pickle/copy round-trip intent re-pinned on string_format).
- #243: SetManager() call -> TypeError not callable; missing-member
  remove() -> KeyError (mixed-args call still applies the present
  removal).
- #245/#263: add_with_encoding -> AttributeError; bytes add() ->
  TypeError with decode hint.
- #256: unknown tuple-manager keys -> AttributeError naming known keys;
  regexes proxy raises naming the missing key; sunder probes now report
  absent (AttributeError) instead of returning manager defaults.
- #261: constants=None raises TypeError at constructor, positional, and
  C-setter entry points (class renamed ConstantsNoneRemovalTests).
- deliberate 2.0 divergences pinned: custom nickname delimiters raise
  pointing at Policy (spec section 3); plain-iterable set-field
  assignment auto-wraps in a validated SetManager where v1 demanded a
  pre-built one (bare strings still raise, #241 stays impossible);
  class-not-instance constants message no longer carries the "did you
  mean Constants()" hint.

Bucket C (shared-CONSTANTS mutation):
- test_can_change_global_constants wraps its shared mutation in
  pytest.deprecated_call (it exercises exactly that deprecation).

2 of 94 tests still fail on one remaining v1.4-parity gap -- the shim
Constants.__init__ lacks the patronymic_name_order/middle_name_as_last
bool kwargs -- so the file stays in collect_ignore_glob and the mypy
exclude with a pointer comment; remove both once the kwargs land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
patronymic_name_order and middle_name_as_last were v1.4 constructor
kwargs (docs/customize.rst doctests use them); the M12 reconciliation
of test_constants.py caught the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Constants(patronymic_name_order=True) / Constants(middle_name_as_last=
True) work again (c1b5730), so the two held ConstantsReprTests pass:
94/94 green. Removes the file's collect_ignore_glob and mypy-exclude
entries and settles the three mypy items its un-excluding surfaced
(|= re-wrap annotations, a dict[str, object] state annotation, and a
note that the 2.0 TupleManager subscripts via dict's __class_getitem__
rather than v1's Generic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
23 tests -> 22, all green.

Bucket B (scheduled removals / deliberate divergences):
- the two #254 None-mode scrubbing regressions merge into one residual
  test: the None display mode is gone (#255), but literal 'None'/'Nonez'
  name text must still survive formatting unscathed.
- regexes.emoji/.bidi = False opt-outs raise TypeError in 2.0 (migration
  spec section 3 uniform rule); the tests now pin the error naming the
  Policy(strip_emoji/strip_bidi=False) replacements.

Bucket C (shared-CONSTANTS mutation):
- the four *_constants_attribute tests (string_format, capitalize_name,
  force_mixed_case_capitalization, both) move onto a private Constants
  passed as constants= -- the migration-guide idiom -- instead of
  mutating the shared singleton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
33 tests -> 31, all green.

Bucket B: the two #255 None-mode regressions (literal-"None"
interpolation, all-empty fallback) collapse to one test pinning the 2.0
invariant -- a fully-empty initials() renders ''.

Bucket C: the three *_constants tests (initials_format,
initials_delimiter, delimiter+separator+format combo) move onto a
private Constants passed as constants= instead of mutating the shared
singleton; the fixture-restore sentinel test keeps asserting the shared
default is ' '.

Bucket E: the #232 doubled-space test's direct middle_list assignment
becomes field assignment; the v1 hole it pinned (unnormalized *_list
elements crashing initials) is unreachable in 2.0 -- list snapshots plus
setter whitespace normalization -- so it now pins the normalized result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… bug

The file needs no reconciliation edits (20 pass, 1 pre-existing v1
xfail), but one test fails on a render-layer parity bug: _render.py's
_cap_word lowercases every conjunction-set word, missing v1
is_conjunction's is_an_initial exclusion, so an initial that doubles as
a conjunction stays lowercase:

    HumanName('scott e. werner').capitalize() -> 'Scott e. Werner'
    (v1: 'Scott E. Werner'; 'e' is in CONJUNCTIONS)

The file stays in collect_ignore_glob with a pointer comment; it is
mypy-clean and leaves the mypy exclude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The file needs no reconciliation edits (8 pass, 2 pre-existing v1
xfails), but one test fails on a confirmed pipeline parity bug: in the
family-comma format, a trailing suffix inside the family segment is not
split out of it.

    Parser().parse('Smith Jr., John') -> family='Smith Jr.', suffix=''
    (v1: last='Smith', first='John', suffix='Jr.')

Stays in collect_ignore_glob with a pointer comment; mypy-clean, so it
leaves the mypy exclude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
57 tests: the stale 'U.S. District Judge' xfail is removed -- the 2.0
period-joined title derivation makes the long-desired expectation hold
(logged in docs/superpowers/plans/notes-m12-diffs.md for the M14
expected-changes list). 54 pass, 1 pre-existing v1 xfail remains.

2 tests fail on confirmed pipeline parity bugs, so the file stays in
collect_ignore_glob (mypy-clean, leaves the mypy exclude):

1. _lexicon._normalize strips INTERIOR periods, so 'J.R.' normalizes to
   'jr' -- a TITLES vocabulary entry; v1's lc() kept 'j.r' unmatched.
     HumanName('Smith, J.R.') -> title='J.R.'
     (v1: first='J.R.', last='Smith')

2. _assign's nickname rule (plan deviation #2) counts name pieces AFTER
   title peeling; v1's p_len==1 counted the whole segment before it.
     HumanName('Xyz. (Bud) Smith') -> last='Smith'
     (v1: title='Xyz.', first='Smith', nickname='Bud')

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All pinned live against v1.4 (2026-07-17):

- _normalize now matches v1's lc(): casefold + EDGE periods only.
  'J.R.' no longer collapses to 'jr' and hits the periodless titles
  vocabulary. Suffix-ACRONYM membership alone uses the period-free
  form ('M.D.' -> 'md'), mirroring v1's is_suffix, which removed
  periods only for the acronym test. The capitalization-exception
  lookup tries both forms (v1 cap_word parity), keeping
  'phd' -> 'Ph.D.' idempotent.
- _cap_word's conjunction lowering excludes initial-shaped words
  (v1 is_conjunction): 'scott e. werner' capitalizes to
  'Scott E. Werner'; an uppercase lone 'Y' stays 'Y'.
- FAMILY_COMMA's family segment peels per-piece suffixes after the
  first piece ('Smith Jr., John' -> family=Smith, suffix=Jr.).
- the lone-piece nickname rule counts the segment BEFORE title
  peeling ('Xyz. (Bud) Smith' -> title, given, nickname).

Six case rows pin the matrix; the three stale v2 unit tests that
pinned the pre-fix semantics are updated with v1 citations.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants