Skip to content

Commit c9d5a7f

Browse files
authored
Merge pull request #294 from derek73/v2.1/cjk-defaults
Parse native-script CJK names by default and add the ZH pack (#271)
2 parents 1484006 + 5a52aab commit c9d5a7f

41 files changed

Lines changed: 2051 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 6 additions & 4 deletions
Large diffs are not rendered by default.

docs/concepts.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ title; particles join forward, so ``de la`` attaches to ``Vega``.
5656
Whatever the vocabulary layer has not claimed is left to a positional
5757
layer, which assigns purely by where a word sits: the first unclaimed
5858
word is the given name, the last is the family name, and anything
59-
between them is the middle name. ``name_order`` and an explicit comma
60-
change what "first" and "last" mean here; nothing else does.
59+
between them is the middle name. ``name_order``, an explicit comma,
60+
and — for a name written wholly in one East Asian script —
61+
``script_orders`` change what "first" and "last" mean here; nothing
62+
else does.
6163

6264
This is the whole parser in two sentences, and it explains its
6365
character. A word nameparser has never seen still gets a sensible role,

docs/customize.rst

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,58 @@ family-then-given regardless of the configured order:
303303
>>> family_first.parse("Thomas, John").family
304304
'Thomas'
305305

306+
East Asian defaults, and turning them off
307+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
308+
309+
Two defaults key on the *script* a name is written in rather than on
310+
anything you set: a name written wholly in Han or Hangul is assigned
311+
family-first (``script_orders``), and an unspaced hangul name is split
312+
into surname and given name against the shipped Korean census list
313+
(``segment_scripts``). :ref:`east-asian-names` explains the naming
314+
conventions both rest on — this section is how to switch them off,
315+
which you can do separately:
316+
317+
.. doctest::
318+
319+
>>> parse("김민준").family # both defaults on
320+
'김'
321+
>>> positional = Parser(policy=Policy(script_orders={}))
322+
>>> positional.parse("김민준").family # still split
323+
'민준'
324+
>>> unsplit = Parser(policy=Policy(segment_scripts=()))
325+
>>> unsplit.parse("김민준").family # one token, not split
326+
'김민준'
327+
328+
The two switches interact, and clearing only ``script_orders``
329+
produces a third behavior rather than the old one: the split still
330+
runs, so ``김민준`` still becomes two tokens, and the positional
331+
default then assigns them given-first — the surname lands in
332+
``given``. To restore nameparser 2.0's reading exactly, clear both
333+
fields.
334+
335+
To teach the splitter a surname it doesn't ship with, add it to the
336+
``surnames`` vocabulary like any other word:
337+
338+
.. doctest::
339+
340+
>>> lex = Lexicon.default().add(surnames={"김민"})
341+
>>> Parser(lexicon=lex).parse("김민준").family
342+
'김민'
343+
344+
Chinese surnames are deliberately absent from that default set,
345+
because splitting Han text requires knowing Chinese from Japanese;
346+
:doc:`locales` covers the opt-in ``zh`` pack that supplies them.
347+
348+
.. note::
349+
350+
Both fields are annotated with their canonical *storage* type
351+
rather than with everything the constructor accepts — the same as
352+
``capitalization_exceptions``. Under mypy the readable spellings
353+
above (``script_orders={...}``, ``segment_scripts=(...)``) need a
354+
``# type: ignore[arg-type]``; ``script_orders=()`` and
355+
``segment_scripts=frozenset(...)`` check clean and mean the same
356+
thing.
357+
306358
Nicknames, maiden names, and brackets
307359
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
308360

docs/design/nameparser-2.0-rfc.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,21 @@ parser_for(locales.RU).parse("Ivanova Anna Sergeevna").given # "Anna"
201201

202202
A locale pack is pure data: a vocabulary fragment plus a partial policy
203203
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.
204+
(formalizing the patronymic support added in 1.3.0); Vietnamese and
205+
Japanese (via a `nameparser[ja]` extra with a pluggable segmenter) are
206+
designed and staged for later 2.x minors. Vocabulary that cannot
207+
misfire on other languages' names (e.g. Cyrillic honorifics) goes in
208+
the default lexicon instead of packs.
209+
210+
*Amended for 2.1 (#271):* the Chinese/Korean work landed, and split
211+
along that last line rather than shipping as one pack. Korean surnames
212+
and family-first order for native-script CJK cannot misfire on other
213+
languages' names, so both became defaults; only the Chinese surname
214+
list needed a pack (`ZH`), because it would corrupt the Japanese names
215+
written in the same characters. The never-auto-detect rule holds as
216+
written for a name's *language*; its **script** is a different signal,
217+
and where the script alone settles a convention, behavior may key on
218+
it.
210219

211220
## Migration: what actually happens to existing code
212221

docs/locales.rst

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ precedes the *given* name — as Arabic ones do — so the word after it
5555
isn't read as a family name. Listing it in ``given_name_titles`` alone
5656
raises ``ValueError`` rather than quietly doing nothing.
5757

58+
Two East Asian behaviors are on by default for the same reason, except
59+
that what selects them is the *script* rather than the word. The
60+
background (covered fully under :ref:`east-asian-names` in
61+
:doc:`usage`): Chinese, Japanese, and Korean names put the family name
62+
first in native script and are usually written with no space between
63+
the parts. Both defaults follow from facts the script alone
64+
establishes. A name written wholly in Han or Hangul is assigned
65+
family-first, because every language written in those scripts orders
66+
names that way — no language guess is involved. An unspaced hangul
67+
name is additionally split into surname and given name, because hangul
68+
is written by nothing but Korean and Korean surnames are a closed
69+
census set that ships as default vocabulary. Splitting an unspaced
70+
*Han* name is the one behavior the script cannot license — the same
71+
characters could be a Chinese or a Japanese name, and a Chinese
72+
surname list splits Japanese names in the wrong place — so it is not a
73+
default: the ``zh`` pack below turns it on when you can declare the
74+
data Chinese.
75+
5876
A pack is for something different: a *structural* rule, like reordering
5977
a patronymic, that vocabulary alone can't express.
6078

@@ -91,7 +109,7 @@ takes:
91109
.. doctest::
92110

93111
>>> locales.available()
94-
('ru', 'tr_az')
112+
('ru', 'tr_az', 'zh')
95113
>>> locales.get("ru") is locales.RU
96114
True
97115

@@ -114,10 +132,19 @@ parsing, equivalent to ``parser_for(locales.get("ru"))``.
114132
(``oglu``, ``qizi``, ``uulu``, and their Latin- and
115133
Cyrillic-script variants) and reads the name around it as
116134
given/middle/family.
117-
118-
Both shipped packs are policy-only — they carry no vocabulary of their
119-
own; see :doc:`concepts` for why that split (language vocabulary vs.
120-
behavior) is drawn where it is.
135+
* - ``zh``
136+
- Chinese surname segmentation — splits an unspaced Han name into
137+
surname and given name (``毛泽东`` → family ````, given
138+
``泽东``) against the surname list the pack ships. It sets no
139+
name order: native-script Han already reads family-first
140+
without a pack.
141+
142+
``ru`` and ``tr_az`` are policy-only — they carry no vocabulary of
143+
their own. ``zh`` is both halves at once: a surname list, plus the one
144+
policy field that turns segmentation on for the script it covers. See
145+
:doc:`concepts` for how that split (language vocabulary vs. behavior)
146+
is drawn, and `Contributing a pack to nameparser`_ for which half a
147+
new naming rule belongs in.
121148

122149
.. warning::
123150

@@ -158,7 +185,9 @@ right and is validated on its own, before it is unioned onto the base
158185
— so a fragment that marks a word must also carry the word it marks.
159186
To make an existing base title precede the given name, restate the
160187
title in the fragment rather than listing it in ``given_name_titles``
161-
alone. Both shipped packs are policy-only, so neither hits this.
188+
alone. ``zh`` is the shipped worked example: its
189+
``Lexicon(surnames=...)`` has to satisfy every ``Lexicon`` rule
190+
standing alone, before anything unions it onto the base.
162191

163192
.. doctest::
164193

@@ -202,24 +231,41 @@ by ``tests/v2/test_locales.py``:
202231
string, return whether *this pack alone* might parse it differently
203232
from the default parser. Over-declaring is safe; under-declaring is
204233
not — when in doubt, ``DEVIATES`` should say yes.
205-
#. Add a rotator list to ``tests/v2/test_locales.py`` with at least one
206-
name exercising every alternation branch of every marker regex the
207-
pack defines — ``test_rotators_cover_every_marker_branch`` fails
208-
until each branch is hit.
234+
#. Add a rotator list to ``tests/v2/test_locales.py``. Every pack needs
235+
one, but what it has to contain follows from how the pack declares
236+
its scope. A pack declaring by *marker regex* (``ru``, ``tr_az``)
237+
needs at least one name exercising every alternation branch of every
238+
regex it defines — ``test_rotators_cover_every_marker_branch`` fails
239+
until each branch is hit. A pack declaring by *codepoint range*
240+
(``zh``) has no branches to sweep and drops out of that test, so its
241+
rotators have to carry the same weight by hand: the unspaced names
242+
the pack must split, one per shape of the vocabulary it ships —
243+
single surname, compound surname, and any spelling variant it means
244+
to cover.
209245
#. Keep the non-interference gate green over the shared corpus plus
210246
your rotators: every name the packed parser parses differently from
211247
the default must be one your ``DEVIATES`` predicate flags — no
212248
silent, undeclared side effects on names outside the pack's stated
213249
scope.
214-
#. Keep the pack policy-only in 2.0 — ``ru`` and ``tr_az`` both ship
215-
an empty :class:`~nameparser.Lexicon`; a pack that wants to carry
216-
its own vocabulary is a later conversation.
250+
#. Decide which layer the vocabulary belongs in, if the pack carries
251+
any. Vocabulary that is *self-selecting* — able to match only text of
252+
the tradition it came from, the way a hangul surname can only ever
253+
match hangul — is default-safe, and belongs in the default lexicon
254+
(``nameparser/config/``) rather than in a pack: a pack nobody knows
255+
to ask for is vocabulary nobody gets. Vocabulary that *declares a
256+
language its script does not* — a Chinese surname list, which
257+
silently mangles the Japanese names written in the same characters
258+
— belongs in the pack, where asking for it is the declaration.
259+
``ru`` and ``tr_az`` need no vocabulary at all and ship an empty
260+
:class:`~nameparser.Lexicon`; ``nameparser/locales/zh.py`` is the
261+
template for one that does.
217262
#. Curate vocabulary conservatively, the same rule as
218263
:doc:`customize`: when you're unsure whether a word or a marker
219264
belongs, leave it out.
220265

221-
``nameparser/locales/ru.py`` is the reference implementation to copy
222-
from. Staged packs in progress are tracked in issues `#271
223-
<https://github.com/derek73/python-nameparser/issues/271>`_, `#272
224-
<https://github.com/derek73/python-nameparser/issues/272>`_, and `#146
225-
<https://github.com/derek73/python-nameparser/issues/146>`_.
266+
``nameparser/locales/ru.py`` is the reference implementation for a
267+
policy-only pack, ``nameparser/locales/zh.py`` for one that carries
268+
vocabulary. Packs still in progress are tracked in issues `#272
269+
<https://github.com/derek73/python-nameparser/issues/272>`_ (Japanese)
270+
and `#146 <https://github.com/derek73/python-nameparser/issues/146>`_
271+
(Vietnamese).

docs/migrate.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,45 @@ into ``middle``/``last`` (``"Jane Smith née Jones"``), and with a
339339
custom suffix delimiter configured, a no-space delimiter group renders
340340
whole (``"RN/CRNA"``) where 1.x split it (``"RN, CRNA"``) — the role
341341
assignment is identical, only the rendered string differs.
342+
343+
2.1 adds two more, and unlike most of the 2.0 API these do reach
344+
``HumanName``: a name written wholly in Han or Hangul is read
345+
family-first, and an unspaced Korean name is split into surname and
346+
given name.
347+
348+
.. doctest::
349+
350+
>>> HumanName("毛 泽东").last
351+
'毛'
352+
>>> HumanName("김민준").last, HumanName("김민준").first
353+
('김', '민준')
354+
355+
1.4 read the first as ``first="毛"``/``last="泽东"``, and left the
356+
second whole in ``first``. The Korean one also changes what the name
357+
*renders* as — ``str(HumanName("김민준"))`` was ``"김민준"`` and is now
358+
``"민준 김"``, because the split inserts a token boundary that the
359+
default format then writes given-name-first.
360+
361+
A third shape changes even though nothing splits it. 1.4 routed a lone
362+
token to ``first`` whatever it was, so an unspaced Chinese or Japanese
363+
name landed there entire; 2.1 reads it as native-script CJK and puts it
364+
in ``last`` instead. Nothing is segmented — Han splitting is opt-in
365+
through a locale pack and ``HumanName`` cannot apply one — but the
366+
field the whole string arrives in is different:
367+
368+
.. doctest::
369+
370+
>>> HumanName("毛泽东").last, HumanName("毛泽东").first
371+
('毛泽东', '')
372+
>>> HumanName("山田太郎").last, HumanName("山田太郎").first
373+
('山田太郎', '')
374+
375+
Both were ``first`` under 1.4. If you feed unspaced CJK through
376+
``HumanName`` and read ``first``, that is the change most likely to
377+
reach you, and it is silent — the string is intact, just in the other
378+
field.
379+
380+
``Constants`` has no switch for any of this — the v1 configuration
381+
surface is frozen for 2.x — so the way out is the 2.0 API:
382+
``Parser(policy=Policy(script_orders={}, segment_scripts=()))``
383+
restores 1.4's reading of all three shapes.

docs/modules.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ Configuration
7373
.. autoclass:: nameparser.PatronymicRule
7474
:members:
7575

76+
.. autoclass:: nameparser.Script
77+
:members:
78+
7679
.. _name-order-constants:
7780

7881
Name-order constants
@@ -102,6 +105,21 @@ because only these three orders have defined assignment semantics.
102105
Family name first, given name *last*, the words between middle —
103106
e.g. Vietnamese full-name order.
104107

108+
.. py:data:: nameparser.DEFAULT_SCRIPT_ORDERS
109+
:value: ((Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST))
110+
111+
The default :attr:`~nameparser.Policy.script_orders` table: a name
112+
written wholly in Han or Hangul reads family-first. A matching
113+
entry in this table takes precedence over ``name_order``, including
114+
a ``name_order`` you set explicitly — ``name_order`` governs only
115+
the names no entry matches. The values are drawn from the same
116+
three constants above, and the same restriction applies. Build on it for
117+
additive customization —
118+
``script_orders=dict(DEFAULT_SCRIPT_ORDERS) | {Script.HAN:
119+
GIVEN_FIRST}`` — and pass ``script_orders={}`` to opt out entirely
120+
and get the purely positional read back. Latin-script and
121+
mixed-script names are never affected either way.
122+
105123
Delimiter defaults
106124
^^^^^^^^^^^^^^^^^^
107125

@@ -171,6 +189,8 @@ HumanName.config Defaults
171189
:members:
172190
.. automodule:: nameparser.config.maiden_markers
173191
:members:
192+
.. automodule:: nameparser.config.surnames
193+
:members:
174194
.. automodule:: nameparser.config.capitalization
175195
:members:
176196
.. automodule:: nameparser.config.regexes

docs/release_log.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
Release Log
22
===========
3+
* 2.1.0 - Unreleased
4+
5+
**East Asian name support**
6+
7+
- Add the Chinese locale pack ``locales.ZH`` — opt-in Han segmentation for unspaced names like ``毛泽东``, with the surname vocabulary it needs. A pack rather than a default because a Chinese surname list corrupts the Japanese names written in the same characters (``高橋一郎`` would split ```` + ``橋一郎``); Japanese needs the pluggable segmenter staged in #272. It sets no name order, since native-script Han already reads family-first without it. ``locales.available()`` is now ``('ru', 'tr_az', 'zh')`` (#271)
8+
- Add ``Lexicon.surnames``, ``Policy.script_orders``, ``Policy.segment_scripts``, the ``Script`` enum and the ``DEFAULT_SCRIPT_ORDERS`` constant to the public API. This is the first behavior nameparser keys on the script a name is written in; it is allowed only where the script itself settles a convention, never as a proxy for guessing the language (#271)
9+
- Add ``AmbiguityKind.SEGMENTATION``, reported when a surname split had a vocabulary-supported alternative: ``"남궁민수"`` is 남궁 + 민수 by the compound surname but 남 + 궁민수 by the single-syllable one, and longest-match had to pick. A name with only one possible split decided nothing and reports nothing (#271)
10+
11+
**Breaking Changes**
12+
13+
- Change the pickle compatibility of the 2.0 API's ``Policy`` and ``Lexicon``: the new fields change the field layout their guarded ``__setstate__`` checks, so a pickle written by 2.0.0 raises ``ValueError`` naming the missing fields instead of loading. Re-pickle after upgrading. ``HumanName`` pickles are unaffected — the facade pickles v1-shaped component state, not these objects
14+
15+
**Behavior Changes**
16+
17+
- Fix names written wholly in Han or Hangul parsing given-first: native-script CJK now reads family-first by default, through the new ``Policy.script_orders`` table, so ``"毛 泽东"`` gives family ``毛`` where 1.x gave family ``泽东``. A name that is a single unspaced token moves the same way — ``"毛泽东"`` and ``"山田太郎"`` now land in ``family``/``last`` where 1.x put them in ``given``/``first``, with the string itself untouched, which makes it the easiest form of this change to miss — a lone token renders the same whichever field holds it, whereas the spaced form does show up in the output (``str(HumanName("毛 泽东"))`` is now ``"泽东 毛"``). No language detection is involved: Chinese and Japanese both write the family name first in native script, so the script settles the order without anyone having to know which language it is. Latin-script and mixed-script names are never affected, and an explicit comma still wins. **Default-on: changes parse output for wholly-CJK names**, through ``HumanName`` as well as the 2.0 API (closes #271)
18+
- Fix unspaced Korean names not splitting: the census surname list now ships as default vocabulary (``Lexicon.surnames``) with hangul segmentation on by default (``Policy.segment_scripts``), so ``"김민준"`` parses family ````, given ``민준`` where 1.x returned the whole string as ``first``. Rendering follows the split, so ``str(HumanName("김민준"))`` is now ``"민준 김"`` where 1.x echoed the input back unchanged. Nothing but Korean is written in hangul and its surnames are a closed census set, which is what makes the split safe as a default rather than a pack. **Default-on**, and it reaches ``HumanName`` too. ``Policy(segment_scripts=())`` turns the split off; ``Policy(script_orders={})`` separately restores the positional reading; clearing both restores 2.0 behavior exactly (#271)
19+
320
* 2.0.0 - July 27, 2026
421

522
Two release candidates preceded this release (rc1 on 2026-07-23,

0 commit comments

Comments
 (0)