Skip to content

Commit 17cc254

Browse files
authored
Merge pull request #290 from derek73/claude/v2-core-api-critique-91a960
Post-rc1 API polish: StrEnum Role, Parser conveniences, dead-vocabulary repair
2 parents 279cb4e + 9d42aaa commit 17cc254

26 files changed

Lines changed: 973 additions & 100 deletions

AGENTS.md

Lines changed: 5 additions & 3 deletions
Large diffs are not rendered by default.

docs/concepts.rst

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ relatives); a position cannot be confused with a look-alike.
3838
assignment, ever. If a parse is almost right and you want to fix one
3939
field, you call ``.replace()``, which returns a new
4040
:class:`~nameparser.ParsedName` with that field changed and everything
41-
else — tokens, spans, the rest of the roles — carried over unchanged.
41+
else — tokens, spans, the rest of the roles — carried over unchanged
42+
(``replace()`` tokens carry no vocabulary tags; :meth:`Parser.revise
43+
<nameparser.Parser.revise>` is the tag-preserving form).
4244
``str()`` renders the default view; nothing about calling it mutates
4345
the value you called it on.
4446

@@ -148,10 +150,10 @@ Some calls are irreducibly ambiguous — both readings are legitimate,
148150
and no amount of rule-tuning resolves them without breaking some other
149151
name. Those surface as entries on ``ParsedName.ambiguities`` instead
150152
of being silently guessed away. The canonical example: a leading "Van"
151-
in "Van Johnson" reads as a given name (that's the common case for
152-
that shape), but "Van" is also a family-name particle in plenty of
153-
other names, so the parse records a ``particle-or-given`` ambiguity
154-
alongside its answer. You can inspect ``ambiguities`` to decide, case
153+
reads as a given name the right call for the actor Van Johnson, the
154+
wrong one for a bare "Van Buren", and nothing in the two-word shape
155+
distinguishes them — so the parse records a ``particle-or-given``
156+
ambiguity alongside its answer. You can inspect ``ambiguities`` to decide, case
155157
by case, whether your data needs a second look.
156158

157159
An ambiguity records a *decision*, not a word. The same token in a
@@ -174,7 +176,8 @@ signal to act on; do not read an empty one as a guarantee.
174176
:class:`Tokens <nameparser.Token>` also carry tags — a second, independent label alongside their
175177
role, recording how a token was classified rather than what part of
176178
the name it belongs to — but only a handful of them are part of the
177-
stable API: ``particle``, ``conjunction``, ``initial``, and ``joined``.
179+
stable API, collected in :data:`~nameparser.STABLE_TAGS`: ``particle``,
180+
``conjunction``, ``initial``, and ``joined``.
178181
Any tag written with a namespace prefix, like ``vocab:...``, is
179182
provenance information for debugging how a token got classified — it
180183
can change shape between releases and isn't something to match against

docs/customize.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ accepts a plain set of lowercase words, keyword by field name (``titles``
2828
above; ``particles``, ``suffix_words``, and the rest work the same
2929
way) — see :doc:`modules` for the full field list.
3030

31+
Vocabulary entries are matched one word at a time (``given_name_titles``
32+
excepted), so a multi-word entry like ``titles={"grand moff"}`` can
33+
never match; the constructor warns when it sees one
34+
(``capitalization_exceptions`` keys included — they are looked up per
35+
word too).
36+
3137
Removing works the same way, and drops the word from recognition:
3238

3339
.. doctest::
@@ -260,6 +266,16 @@ listed below.
260266
- Excludes bidirectional control characters the same way.
261267
Defaults to ``True``.
262268

269+
To apply a :class:`PolicyPatch <nameparser.PolicyPatch>` directly --
270+
without going through a locale pack -- call :meth:`Policy.patched()
271+
<nameparser.Policy.patched>`:
272+
273+
.. doctest::
274+
275+
>>> from nameparser import Policy, PolicyPatch
276+
>>> Policy().patched(PolicyPatch(middle_as_family=True))
277+
Policy(middle_as_family=True)
278+
263279
Family-first name order
264280
~~~~~~~~~~~~~~~~~~~~~~~~
265281

docs/locales.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ You don't need to touch nameparser's registry to use your own pack —
149149
:class:`~nameparser.PolicyPatch` is a :class:`~nameparser.Policy`-shaped
150150
patch: every field defaults to :data:`~nameparser.UNSET` (leave it
151151
alone) instead of to a concrete value, so a pack only ever states what
152-
it changes.
152+
it changes. A patch can also be applied directly, without a pack —
153+
see :meth:`Policy.patched() <nameparser.Policy.patched>`.
153154

154155
The ``policy`` half works that way, but the ``lexicon`` half does not.
155156
A pack's :class:`~nameparser.Lexicon` is a complete value in its own

docs/modules.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ Results
2323
.. autoclass:: nameparser.Token
2424
:members:
2525

26+
.. py:data:: nameparser.STABLE_TAGS
27+
:value: frozenset({"particle", "conjunction", "initial", "joined"})
28+
29+
The four :attr:`Token.tags <nameparser.Token.tags>` values that are
30+
stable API: ``particle`` (a word from the particle vocabulary,
31+
"de"/"van", wherever it lands — combine with ``Role.FAMILY`` for
32+
actual family particles),
33+
``conjunction`` (a joining word, "and"/"y"), ``initial`` (an
34+
initial-shaped word, "J."), and ``joined`` (a continuation of the
35+
previous token within one merged piece, so the suffix view renders
36+
"Ph. D." as one credential). Every other tag is namespaced
37+
(``vocab:...``) and unstable — never match against those.
38+
2639
.. autoclass:: nameparser.Span
2740
:members:
2841

docs/release_log.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ Release Log
3030
- Add ``name_order`` and the constants ``GIVEN_FIRST``, ``FAMILY_FIRST`` and ``FAMILY_FIRST_GIVEN_LAST``, so a family-first name can be parsed as written rather than reordered by hand (closes the configuration half of #270)
3131
- Add ``PatronymicRule`` with the members ``EAST_SLAVIC`` and ``TURKIC``. v1's single ``patronymic_name_order`` flag enabled both detectors at once; ``Policy(patronymic_rules=...)`` lets you enable either one alone
3232
- Add ``PolicyPatch`` and the ``UNSET`` sentinel for partial policy deltas that compose -- set-valued fields union, scalar fields override with later winning. This is the mechanism locale packs are built from, and ``UNSET`` is only needed when you must distinguish "not set" from a real ``False`` or ``None``
33-
- Add ``Token``, ``Span`` and ``Role``: every field is backed by tokens carrying exact ``(start, end)`` offsets into the original string, reachable with ``tokens_for(Role.GIVEN)``. This replaces v1's ``*_list`` attributes and makes it possible to highlight or re-slice the input the parse came from
33+
- Add ``Token``, ``Span`` and ``Role``: every field is backed by tokens carrying exact ``(start, end)`` offsets into the original string, reachable with ``tokens_for(Role.GIVEN)``. This replaces v1's ``*_list`` attributes and makes it possible to highlight or re-slice the input the parse came from. ``Role`` is a ``StrEnum``, so members compare and stringify as their field names (``Role.GIVEN == "given"``), matching ``AmbiguityKind``; ``tokens_for()`` accepts a role's string name too, and raises ``ValueError`` for an unknown role
34+
- Add ``STABLE_TAGS`` to the public API: the four documented ``Token.tags`` values (``particle``, ``conjunction``, ``initial``, ``joined``)
35+
- Add ``Policy.patched(patch)``, applying a ``PolicyPatch`` directly without wrapping it in a locale pack
36+
- Add ``Parser.matches(a, b)`` and ``Parser.capitalized(name)``: the ``ParsedName`` methods of the same names fall back to the default configuration for str/omitted arguments, which is silently wrong for names parsed with a custom ``Parser``
37+
- Add ``Parser.revise(name, **fields)``: ``ParsedName.replace()`` with the replacement text classified by the parser's vocabulary, so particle/initial/suffix-join behavior survives the edit
3438
- Add ``Ambiguity`` and the ``AmbiguityKind`` enum, so a parse reports what it had to guess at instead of guessing silently. The kinds emitted today are ``particle-or-given``, ``suffix-or-name``, ``suffix-or-nickname``, ``unbalanced-delimiter`` and ``comma-structure``; ``order`` is reserved and not yet emitted. The two suffix kinds cover the post-nominals that are also ordinary words: ``"John Smith MA"`` reports that ``MA`` was read as a credential rather than a surname, and ``"JEFFREY (JD) BRICKEN"`` that the delimited ``JD`` was read as a nickname rather than a suffix. A reading the vocabulary settles on its own — ``"John Smith M.A."``, ``"Andrew Perkins (MBA)"`` — is not a guess and reports nothing
35-
- Add ``ParsedName`` output and comparison methods: ``render(spec)``, ``initials()``, ``capitalized()`` (which returns a new value rather than mutating in place), ``as_dict()``, ``replace(**fields)``, ``matches()`` and ``comparison_key()``
39+
- Add ``ParsedName`` output and comparison methods: ``render(spec)``, ``initials()``, ``capitalized()`` (which returns a new value rather than mutating in place), ``as_dict()`` (whose ``include_empty`` flag is keyword-only, unlike ``HumanName.as_dict()``'s), ``replace(**fields)``, ``matches()`` and ``comparison_key()``
3640
- Add ``Locale``, the public pack type. Writing your own needs no registration -- construct a ``Locale`` and pass it to ``parser_for()``
3741
- Ship a fully typed public API (PEP 561): the core modules are checked under strict mypy settings, and nameparser 2.0 has no runtime dependencies
3842

@@ -60,6 +64,9 @@ Release Log
6064
- Fix the pre-comma piece being routed to ``first`` when everything after the comma is a suffix or title: ``"Andrews, M.D."`` now reads family ``Andrews`` / suffix ``M.D.`` where 1.x read given ``M.D.`` / family ``Andrews``, and ``"Smith, Dr."`` moves ``Smith`` from ``first`` to ``family`` (the title was already correct in 1.x). The piece before a comma is definitionally the family name
6165
- Fix a lone recognized trailing suffix with no comma being routed to ``first``/``last``: ``"Johnson PhD"`` and ``"Mr. Johnson PhD"`` now keep the suffix in ``suffix``
6266
- Fix a split ``"Ph. D."`` credential being read as two tokens; it now classifies as one suffix, replacing v1's ``fix_phd`` hook. This now holds wherever the credential sits: 1.x healed the pair only when it trailed, so ``"Ph. D. John Smith"`` parsed as title ``Ph.`` / given ``D.`` with the real given name pushed to ``middle``; it now reads given ``John``, family ``Smith``, suffix ``Ph. D.``
67+
- Fix ``chargé d'affaires``: shipped as one unmatchable ``TITLES`` entry since it was added, it is now two chainable entries (``chargé``, ``d'affaires``), so the title is recognized; the unaccented spelling ``charge`` ships too, like ``attaché``/``attache``
68+
- Remove seven multi-word ``SUFFIX_ACRONYMS`` entries (``leed ap``, ``nicet i``–``nicet iv``, ``psm i``, ``psm ii``) that could never match in any release; splitting them would swallow real names ("John Leed", "Smith, A.P."), so they are dropped instead
69+
- Add a ``UserWarning`` when a multi-word entry is stored in a per-word ``Lexicon`` field or as a ``capitalization_exceptions`` key -- such entries can never match
6370
- Parse an input with no alphanumeric character to an empty name in both APIs. 1.x kept pure punctuation as a name part, so ``"."`` gave ``first="."`` and ``bool()`` was ``True``; 2.0 empties it, keeping ``bool(parse(x))`` an honest "did I get a name?" test. The check is Unicode-aware, so names in any script are unaffected; only inputs that are entirely punctuation or symbols (``"."``, ``"- -"``) change. Junk embedded in a name with real content -- the stray dot in ``"John . Smith"`` -- is still kept, since that parse is already truthy
6471
- Fold a leading never-given particle into the family name. Note that ``Lexicon.particles_ambiguous`` is the **complement** of v1's ``non_first_name_prefixes``, not a rename -- it lists the particles that *may* double as a given name, where v1 listed the ones that may not. Copying a v1 customization across without inverting it silently reverses the behavior; see :doc:`migrate`
6572
- Add ``ma`` and ``do`` to ``suffix_acronyms_ambiguous``, the set of post-nominals that are also ordinary surnames. An entry there is read as a credential when the name can spare it — written with periods (``"John Smith M.A."``), or when removing it still leaves a given *and* a family name (``"John Smith MA"`` → suffix ``MA``). With only two pieces to go around, the surname reading wins instead: ``"Jack Ma"`` and ``"Anh Do"`` keep their family names. As a side effect, a parenthesized or quoted ``"(MA)"``/``"(DO)"`` now falls through to nickname parsing rather than escaping to ``suffix``, since inside delimiters the nickname reading is the plausible one
@@ -81,6 +88,7 @@ Release Log
8188

8289
- Reimplement ``HumanName`` as a facade over the 2.0 pipeline, and ``Constants`` as a shim resolving to a ``(Lexicon, Policy)`` snapshot with a shared parser cache. Fields, aggregates, mutation through ``name.C.titles.add(...)``, rendering defaults, ``capitalize()``, ``matches()``, ``comparison_key()``, iteration, ``as_dict()`` and pickling are all preserved, and ``nameparser.parser`` and ``nameparser.config`` remain importable. The compatibility layer ships through 2.x and is removed in 3.0
8390
- Note that ``CONSTANTS.capitalize_name`` and ``force_mixed_case_capitalization`` are still honored through the facade, but the 2.0 API never capitalizes during ``parse()`` -- call ``capitalized()`` when you want it
91+
- Add ``Role`` members (and their string values ``given``/``family``) as valid ``HumanName`` subscript keys: ``hn[Role.GIVEN]`` returns ``hn.first``
8492

8593
**Command line**
8694

@@ -98,6 +106,16 @@ Release Log
98106
tooling, not part of the installed package) -- see its README to
99107
reproduce the comparison against your own names.
100108

109+
**Changed since 2.0.0rc1** (for anyone who tested the release candidate)
110+
111+
- ``Role`` became a ``StrEnum``; ``str(Role.GIVEN)`` is now ``"given"``
112+
- ``ParsedName.tokens_for()`` raises ``ValueError`` for unknown roles instead of returning no tokens; it also accepts role-name strings
113+
- ``ParsedName.as_dict()``'s ``include_empty`` is keyword-only
114+
- ``HumanName`` subscripting accepts ``Role`` members
115+
- The eight multi-word vocabulary entries that could never match were repaired (``chargé d'affaires`` split; seven credential acronyms removed), and storing a new multi-word entry now warns; those eight are dropped silently, not warned about, when a restored ``Constants`` pickle carries all eight of them (the pre-2.0 signature)
116+
- ``PolicyPatch``'s repr shows only the fields a patch sets, instead of all nine with UNSET sentinels
117+
- New since rc1: ``STABLE_TAGS``, ``Policy.patched()``, ``Parser.matches()``, ``Parser.capitalized()``, and ``Parser.revise()`` -- see the API section above
118+
101119
* 1.4.0 - July 12, 2026
102120

103121
- Add ``Constants.copy()``, a detached deep copy that preserves the source instance's current customizations (unlike ``Constants()``, which always starts from library defaults) -- useful as ``CONSTANTS.copy()`` for a private snapshot of the shared config (#260)

docs/usage.rst

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ Fixing case
188188
>>> str(parse("juan de la vega").capitalized())
189189
'Juan de la Vega'
190190

191+
The no-argument form above uses the DEFAULT lexicon; for a name parsed
192+
with a custom :class:`~nameparser.Parser`, call
193+
:meth:`Parser.capitalized() <nameparser.Parser.capitalized>` so the
194+
parser's own vocabulary decides the exceptions.
195+
191196
Nicknames and maiden names
192197
----------------------------
193198

@@ -297,7 +302,10 @@ Comparing names
297302
``==`` is strict value equality — two :class:`~nameparser.ParsedName`
298303
instances are equal only if every field matches exactly. For "is this
299304
the same name, allowing for order and case?" use ``matches()`` or
300-
``comparison_key()`` instead.
305+
``comparison_key()`` instead. ``matches()`` parses a str argument with
306+
the DEFAULT parser; to compare against a string using a custom
307+
:class:`~nameparser.Parser`'s vocabulary, call
308+
:meth:`Parser.matches() <nameparser.Parser.matches>`.
301309

302310
.. doctest::
303311

@@ -309,16 +317,18 @@ the same name, allowing for order and case?" use ``matches()`` or
309317
When the parser had to guess
310318
-----------------------------
311319

312-
Some names have no single correct reading. ``"Van Johnson"`` could be
313-
the given name ``Van``, or the family-name particle ``van``. 2.0 takes
314-
the more likely reading and *records* the choice on ``ambiguities``
315-
rather than deciding silently:
320+
Some names have no single correct reading. A leading ``Van`` could be
321+
a given name — it really is for the actor Van Johnson — or the start
322+
of a family name, as it is for President Van Buren. Both are the same
323+
shape, so no rule can tell them apart. 2.0 takes the more likely
324+
reading and *records* the choice on ``ambiguities`` rather than
325+
deciding silently:
316326

317327
.. doctest::
318328

319-
>>> name = parse("Van Johnson")
329+
>>> name = parse("Van Buren")
320330
>>> name.given, name.family
321-
('Van', 'Johnson')
331+
('Van', 'Buren')
322332
>>> for a in name.ambiguities:
323333
... print(a.kind.value, "-", a.detail)
324334
particle-or-given - leading 'Van' may be a family-name particle; read as a given name
@@ -385,12 +395,17 @@ so you can always get back to the text a field came from:
385395
>>> [t.text for t in name.tokens_for(Role.FAMILY)]
386396
['de', 'la', 'Vega']
387397

398+
A role's string name works too: ``name.tokens_for("family")``.
399+
388400
Correcting a parse
389401
--------------------
390402

391403
:class:`~nameparser.ParsedName` is immutable, so a correction is a new
392-
value: ``replace()`` returns a copy with the given fields changed and
393-
everything else carried over.
404+
value: ``replace()`` returns a copy with the given fields changed.
405+
Untouched fields keep their tokens (and ``original`` is preserved),
406+
with one deliberate exception: an ambiguity that pointed into a
407+
replaced field is dropped — correcting the field that was flagged
408+
clears the flag, while correcting an unrelated field keeps it.
394409

395410
.. doctest::
396411

@@ -400,6 +415,50 @@ everything else carried over.
400415
'Dr. Juan de la Vega'
401416
>>> name.title
402417
''
418+
>>> flagged = parse("Van Buren")
419+
>>> flagged.replace(given="Martin").ambiguities
420+
()
421+
>>> [a.kind.value for a in flagged.replace(family="Harrison").ambiguities]
422+
['particle-or-given']
423+
424+
``replace()`` splits values on whitespace into plain, untagged
425+
tokens — the vocabulary knowledge a parse would have about the new
426+
text is not there. The views that depend on tags degrade: the parser
427+
no longer knows ``de la`` are particles, so ``family_particles``
428+
empties and the particles start contributing initials.
429+
430+
.. doctest::
431+
432+
>>> name.family_particles
433+
'de la'
434+
>>> replaced = name.replace(family="de la Vega Smith")
435+
>>> replaced.family_particles
436+
''
437+
>>> replaced.initials()
438+
'J. d. l. V. S.'
439+
440+
:meth:`Parser.revise() <nameparser.Parser.revise>` is the same
441+
operation with each value classified by the parser's vocabulary, so
442+
the correction behaves like a fresh parse of the corrected name
443+
(which also means delimiters and marker words in the value are
444+
consumed as they would be in a parse):
445+
446+
.. doctest::
447+
448+
>>> from nameparser import Parser
449+
>>> parser = Parser()
450+
>>> revised = parser.revise(name, family="de la Vega Smith")
451+
>>> revised.family_particles
452+
'de la'
453+
>>> revised.initials()
454+
'J. V. S.'
455+
456+
``revise()`` has two siblings on :class:`~nameparser.Parser`:
457+
:meth:`Parser.matches() <nameparser.Parser.matches>` and
458+
:meth:`Parser.capitalized() <nameparser.Parser.capitalized>`. Those
459+
two matter when you have built a custom parser — the
460+
:class:`~nameparser.ParsedName` methods of the same names fall back
461+
to the *default* configuration for str or omitted arguments.
403462

404463
Command line
405464
------------

nameparser/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
PolicyPatch,
2121
)
2222
from nameparser._types import (
23+
STABLE_TAGS,
2324
Ambiguity,
2425
AmbiguityKind,
2526
ParsedName,
@@ -33,6 +34,7 @@
3334
"HumanName",
3435
# v2 core
3536
"Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName",
37+
"STABLE_TAGS",
3638
"Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET",
3739
"GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST",
3840
"DEFAULT_NICKNAME_DELIMITERS", "Locale",

0 commit comments

Comments
 (0)