Skip to content

Commit 5e08f54

Browse files
authored
Add the nameparser 2.0 design RFC (#285)
Documentation-only: the accepted 2.0 design, landed in docs/design/ as the standing record now that 2.0 has shipped (2.0.0rc1). Amended before merge to match what shipped -- adds the maiden field (#274) and fixes the ambiguity example -- after verifying every code example runs verbatim against the release. Feedback continues on #284.
1 parent 4089d94 commit 5e08f54

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

docs/design/nameparser-2.0-rfc.md

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# RFC: nameparser 2.0 — a new core API
2+
3+
Status: **open for feedback** — no deadline. Implementation may proceed
4+
in parallel; this document will be amended (as commits to this PR) as
5+
implementation teaches us things. Comment on specific lines here, or on
6+
the discussion issue (#284) for general reactions.
7+
8+
## The short version, and the promise
9+
10+
nameparser 2.0 introduces a new, immutable core API and keeps the
11+
existing one working:
12+
13+
```python
14+
from nameparser import parse
15+
16+
name = parse("Dr. Juan Q. Xavier de la Vega III (Doc Vega)")
17+
name.given # "Juan"
18+
name.family # "de la Vega"
19+
name.family_base # "Vega"
20+
name.title # "Dr."
21+
```
22+
23+
**The compatibility promise:** code that runs warning-free on nameparser
24+
1.4 keeps running on every 2.x release, with identical results, getting
25+
at most `DeprecationWarning`s. `HumanName` continues to exist as a
26+
compatibility layer over the new core. The exceptions to "identical
27+
results" are (a) parse-output changes that are deliberate bug fixes,
28+
each documented in the release log with its issue, and (b) three narrow
29+
edge cases listed in the Migration section. Removal of the old API
30+
happens in 3.0, not 2.x, and only after warnings have named every
31+
replacement.
32+
33+
If you only use `HumanName` and never customize configuration, 2.0
34+
should be a non-event for you. The rest of this document is for
35+
everyone else — and for anyone who wants to push on the new design
36+
while it is still cheap to change.
37+
38+
## Why a new core
39+
40+
Fifteen years of issues point at the same architectural limits:
41+
42+
- **`HumanName` is four things in one mutable class** — parser, state,
43+
vocabulary predicates, and formatter. Every feature grows all four at
44+
once (it is currently ~1,600 lines), and mutation semantics are
45+
inconsistent: assigning `full_name` re-parses, assigning `last` does
46+
not, `capitalize()` mutates in place.
47+
- **Parse state is `list[str]`**, so position information is lost and
48+
re-derived by value lookups. That is the structural root cause of a
49+
whole family of prefix-join bugs (#100 and relatives): when the same
50+
string appears twice, the lookup answers the wrong question.
51+
- **Configuration is a shared mutable module global** (`CONSTANTS`).
52+
Most of `nameparser.config`'s ~850 lines exist to make shared
53+
mutation survivable (custom set managers, descriptors, cache
54+
invalidation), and it still causes test pollution and cross-thread
55+
surprises.
56+
- **Equality is broken by design** (#223): case-insensitive equality,
57+
equality with plain strings, and hashability are mutually
58+
inconsistent promises. 1.3.0 deprecated `==`/`hash()` and shipped
59+
`matches()`/`comparison_key()`.
60+
- **The roadmap needs structure v1 can't cheaply provide**: family-first
61+
name order (#270), per-dataset configuration without global mutation,
62+
and honest reporting of genuinely ambiguous parses.
63+
64+
The rules-based approach is not changing. nameparser's niche is
65+
deterministic, auditable, zero-dependency parsing — the same input
66+
always parses the same way. The rewrite reorganizes *how the rules run*,
67+
not what the library is.
68+
69+
## The new API
70+
71+
### Parsing produces an immutable value
72+
73+
`parse()` returns a `ParsedName`: a frozen dataclass. There is no
74+
mutation anywhere in the new API — editing is `replace()`, which
75+
returns a new value:
76+
77+
```python
78+
name = parse("John Smith")
79+
name2 = name.replace(given="Jane") # new object; `name` unchanged
80+
```
81+
82+
`parse()` is **total over `str`**: any string — empty, emoji,
83+
garbage — returns a `ParsedName` rather than raising. Content problems
84+
are represented in the result; the only exceptions are type errors
85+
(`bytes` raises `TypeError` — decode first).
86+
87+
Equality is strict structural equality, and the semantic comparisons
88+
are explicit and separate — `comparison_key()` for dedup/sorting and
89+
`matches()` for component-wise case-insensitive comparison — resolving
90+
the #223 trilemma by not conflating the three meanings of "equal".
91+
92+
### Two access layers: strings for the 95% case, tokens for the rest
93+
94+
The string properties behave like v1's: always `str`, `""` when empty.
95+
Underneath, `ParsedName` carries the actual parse: a tuple of `Token`s,
96+
each with its **span** (position in the original string), its role, and
97+
tags recording *how it was classified*:
98+
99+
```python
100+
name = parse("Juan de la Vega")
101+
name.tokens_for(Role.FAMILY)
102+
# (Token('de' @5:7 FAMILY {particle}),
103+
# Token('la' @8:10 FAMILY {particle}),
104+
# Token('Vega' @11:15 FAMILY),)
105+
```
106+
107+
This is the structural fix for the value-lookup bug family: pipeline
108+
stages address tokens by index, so "which 'de' did you mean?" cannot
109+
arise. It is also the debuggability story — "why is 'Van' in my family
110+
name?" is answered by the token's tags, and `repr(name)` prints a
111+
compact component-per-line breakdown like v1's.
112+
113+
Derived views (`family_base`, `family_particles`, `surnames`,
114+
`given_names`) are pure filters over tokens — they can never disagree
115+
with the parse.
116+
117+
### `given` and `family`, not `first` and `last`
118+
119+
The v1 field names describe Western *position* — they date from before
120+
the parser even supported comma formats, when position was all there
121+
was. They read wrongly the moment name order varies: under family-first
122+
order, `name.last` would return the first word. 2.0 adopts the names
123+
the standards world already uses (HTML autocomplete
124+
`given-name`/`family-name`, vCard, Unicode CLDR):
125+
126+
- `first``given`, `last``family`
127+
- `last_base`/`last_prefixes``family_base`/`family_particles`
128+
- config: `prefixes``particles` (every web standard uses "prefix"
129+
for honorifics like "Mr." — v1's usage collides; the linguistics term
130+
for van/de la is *particle*)
131+
- `title` and `suffix` **stay** — they have ecosystem gravity, and the
132+
positional taxonomy ("pre-nominal"/"post-nominal") is documented
133+
rather than spelled into identifiers.
134+
- `maiden` is a **new** field — a birth surname introduced by a marker
135+
like `née` or `geb.`, which v1 folded into `middle`/`last` (#274).
136+
137+
`HumanName` keeps every v1 spelling, including `string_format` keys, so
138+
existing code is unaffected; only new-API adopters see the new names.
139+
140+
### Configuration: `Lexicon` (vocabulary) and `Policy` (behavior)
141+
142+
v1's `Constants` mixes three kinds of thing; 2.0 splits them by what
143+
varies together:
144+
145+
- **`Lexicon`** — vocabulary data (titles, suffixes, particles,
146+
conjunctions, …). Immutable; composable by union; varies by
147+
*language*.
148+
- **`Policy`** — behavior switches (`name_order`, patronymic rules,
149+
delimiter pairs, …). Immutable; varies by *data source*.
150+
- **Rendering parameters**`string_format`, initials style,
151+
capitalization — move to render-time arguments; they vary by *output
152+
destination* and are no longer configuration at all.
153+
154+
```python
155+
from nameparser import Parser, Lexicon, Policy, FAMILY_FIRST
156+
157+
parser = Parser(
158+
lexicon=Lexicon.default().add(titles={"dra"}),
159+
policy=Policy(name_order=FAMILY_FIRST),
160+
)
161+
parser.parse("Wang Xiuying").family # "Wang"
162+
```
163+
164+
A `Parser` is immutable, thread-safe, and does all its compilation once
165+
at construction — build it at startup, share it everywhere. There is no
166+
shared mutable configuration in the new API, which retires the entire
167+
test-pollution/config-war problem class. `name_order` is an order-spec
168+
tuple (`FAMILY_FIRST = (Role.FAMILY, Role.GIVEN, Role.MIDDLE)`) rather
169+
than a boolean, because Vietnamese order — family, middle, *then*
170+
given — needs a third arrangement (#270).
171+
172+
### Honesty about ambiguity
173+
174+
Some parses are genuinely undecidable by rules — "Kelly Michael" could
175+
be either order; in "Van Johnson", "Van" is a Dutch particle *and* a
176+
common given name. v1 silently picks a reading. 2.0 picks the same
177+
reading **and says so**:
178+
179+
```python
180+
parse("Van Johnson").ambiguities
181+
# (Ambiguity(PARTICLE_OR_GIVEN, "leading 'Van' may be a family-name
182+
# particle; read as a given name", tokens=(...)),)
183+
```
184+
185+
`Ambiguity.kind` values are stable API you can filter on. This is also
186+
groundwork: a future `parse_all()` returning ranked alternative
187+
readings, and `explain()` returning a stage-by-stage parse trace, are
188+
designed for (the pipeline supports both) but deliberately not in 2.0.
189+
190+
### Locales: opt-in packs, never auto-detection
191+
192+
Language-specific behavior cannot be auto-detected — "Tham Jun Hoe" is
193+
token-for-token indistinguishable from a Western three-token name, and
194+
"Ali" is Arabic or Italian. So locale support is explicit:
195+
196+
```python
197+
from nameparser import parser_for, locales
198+
199+
parser_for(locales.RU).parse("Ivanova Anna Sergeevna").given # "Anna"
200+
```
201+
202+
A locale pack is pure data: a vocabulary fragment plus a partial policy
203+
patch, folded in at parser construction. 2.0.0 ships `RU` and `TR_AZ`
204+
(formalizing the patronymic support added in 1.3.0); Chinese/Korean
205+
(bundled surname lists + segmentation of unspaced input), Vietnamese,
206+
and Japanese (via a `nameparser[ja]` extra with a pluggable segmenter)
207+
are designed and staged for 2.x minors. Vocabulary that cannot misfire
208+
on other languages' names (e.g. Cyrillic honorifics) goes in the
209+
default lexicon instead of packs.
210+
211+
## Migration: what actually happens to existing code
212+
213+
**`HumanName` in 2.0** is a wrapper over the new core with v1's exact
214+
mutation semantics, field spellings, and formatting attributes. The v1
215+
test suite runs against it as the compatibility gate. v1 pickles load
216+
throughout 2.x.
217+
218+
**`CONSTANTS` keeps working** — mutations are honored exactly (the
219+
facade rebuilds its config snapshot when the shared config changes) and
220+
emit a `DeprecationWarning` pointing at the replacements. Mutating a
221+
*private* `Constants` instance does not warn.
222+
223+
**Already-scheduled removals land as warned** (every one was deprecated
224+
in 1.3.0 or 1.4 first): `==`/`hash()`, bytes input, slice item access,
225+
`empty_attribute_default`, `constants=None`, silent config-typo
226+
fallbacks, legacy pickle blobs, `add_with_encoding()`. Python floor
227+
becomes 3.11; the last dependency is dropped.
228+
229+
**The three narrow behavior exceptions** (everything else identical):
230+
231+
1. `*_list` attributes return snapshots — in-place
232+
`name.first_list.append(...)` no longer mutates parse state
233+
(assignment still does).
234+
2. Subclasses overriding parsing hooks (`parse_pieces`, `is_title`, …)
235+
are detected and warned at construction: the facade delegates to the
236+
new core, so such overrides are no longer called (#280). If you do
237+
this, we want to hear what your override does — that is a feature
238+
request we would rather absorb than break.
239+
3. Assigning to `CONSTANTS.regexes` raises with a pointer to the named
240+
`Policy` flag that replaces it. Custom regex injection has no
241+
equivalent in the new model; nothing is silently ignored.
242+
243+
**Timeline:** 2.0.0's only *new* warning is on shared-`CONSTANTS`
244+
mutation. A warning on `HumanName` construction itself comes in a later
245+
2.x minor, once the new API has proven out — and 3.0 (which removes the
246+
facade) does not ship before that warning has been out in a release.
247+
248+
## Alternatives considered and rejected
249+
250+
- **Auto-detecting name language/order** — impossible in principle, not
251+
just hard; see the Locales section.
252+
- **A statistical/ML core** — determinism and auditability are the
253+
point of this library; the token/ambiguity model leaves room for
254+
optional escalation later without changing the core.
255+
- **Keeping `first`/`last`** — see the naming section; the facade keeps
256+
them for v1 code, which serves the familiarity constituency without
257+
wiring position-based names into the new API.
258+
- **A boolean `family_name_first`** — cannot express Vietnamese; the
259+
order-spec tuple can (#270).
260+
- **Renaming `title`/`suffix` to `pre_nominal`/`post_nominal`** — more
261+
precise, but no ecosystem uses those as field names; precision lives
262+
in the docs instead.
263+
- **Vocabulary entries as records with per-word metadata** — rejected
264+
under "no vocabulary data without a consuming rule": a flag the
265+
parser doesn't read is a promise it doesn't keep. Cross-category
266+
words are handled by dual set membership (closed classes) or the two
267+
`_ambiguous` sets (closed-vs-open), each of which has a consuming
268+
rule.
269+
- **A user-replaceable regex escape hatch** — replaced by named policy
270+
flags; anything not expressible through them is a feature request we
271+
want to see.
272+
273+
## Feedback we are specifically looking for
274+
275+
No deadline — comment whenever, on lines here or on the discussion
276+
issue. The questions where answers would most change things:
277+
278+
1. **Do you compare `HumanName` objects with `==` (especially against
279+
plain strings), or use them in sets/dicts?** 2.0 changes this to
280+
object identity (warned since 1.3.0; `matches()`/`comparison_key()`
281+
are the replacements). This is the one silent behavior change in the
282+
release.
283+
2. **Do you subclass `HumanName` and override parsing methods?** Tell
284+
us what your override accomplishes — we would rather absorb it as a
285+
feature than break it silently (#280).
286+
3. **Do you mutate `CONSTANTS` after startup** (not just at import
287+
time)? Anything beyond startup-time customization we should know
288+
about?
289+
4. **Do you assign custom regexes to `CONSTANTS.regexes`?** What for?
290+
Each real use case either becomes a named policy flag or stays
291+
broken — we would like the list to be complete before 2.0.
292+
5. **`given`/`family` instead of `first`/`last`** in the new API: does
293+
this help or annoy you?
294+
6. **Is there anything you build on top of nameparser** (wrappers,
295+
pipelines) where the compatibility promise as stated would still
296+
break you?

0 commit comments

Comments
 (0)