Skip to content

Commit 48390f1

Browse files
authored
Merge pull request #297 from derek73/v2.1/ja-segmenter
Japanese names: the kana license by default and a pluggable segmenter (#272)
2 parents c9d5a7f + 332bd7a commit 48390f1

40 files changed

Lines changed: 3258 additions & 287 deletions

.github/workflows/python-package.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,40 @@ jobs:
6161
token: ${{ secrets.CODECOV_TOKEN }}
6262
files: ./coverage.xml
6363
fail_ci_if_error: false
64+
65+
# The optional nameparser[ja] segmenter (#272). Its own job, not a
66+
# matrix entry: the matrix above must keep proving that the package
67+
# installs and passes with NO third-party dependency at all, which is
68+
# the promise the extra is carved out of. Everything the extra adds
69+
# is under tests/v2/ (the ja pack's integration tests, which skip
70+
# without it).
71+
ja-extra:
72+
runs-on: ubuntu-latest
73+
# 3.14, not the matrix's newest: namedivider-python pulls lightgbm,
74+
# which pulls scipy/numpy, and those have no 3.15 wheels yet (the
75+
# sdist build fails). 3.15 is pre-release and this matrix is ahead
76+
# of the ecosystem; revisit when it goes final.
77+
env:
78+
UV_PYTHON: "3.14"
79+
steps:
80+
- uses: actions/checkout@v7
81+
- name: Set up Python 3.14
82+
uses: actions/setup-python@v6
83+
with:
84+
python-version: "3.14"
85+
- name: Install uv
86+
uses: astral-sh/setup-uv@v6
87+
with:
88+
enable-cache: true
89+
- name: Install dependencies
90+
run: uv sync --group dev --extra ja
91+
- name: Run Tests
92+
# The import check first: every test the extra adds is guarded by
93+
# a find_spec("namedivider") skipif, so a job where the extra
94+
# silently failed to install is a job where all of them SKIP and
95+
# the step still passes green. This line is what makes that
96+
# failure loud -- without it the job proves nothing on the day it
97+
# matters.
98+
run: |
99+
uv run python -c "import namedivider"
100+
uv run pytest tests/v2/ -q

AGENTS.md

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

docs/concepts.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ Honest ambiguity
146146
Parsing never raises. Pass in a string that doesn't look like a name
147147
at all, and you get back a :class:`~nameparser.ParsedName` with empty
148148
fields, not an exception. The parser's job is to make a reasonable
149-
call on real-world text, not to reject it.
149+
call on real-world text, not to reject it. The single exception is
150+
code you supplied yourself: a :data:`~nameparser.Segmenter` passed to
151+
``Parser(segmenter=...)`` runs inside the parse, and its own
152+
exceptions propagate rather than being swallowed — a failure there is
153+
a bug in your callable, not a fact about the name.
150154

151155
Some calls are irreducibly ambiguous — both readings are legitimate,
152156
and no amount of rule-tuning resolves them without breaking some other

docs/customize.rst

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,12 @@ East Asian defaults, and turning them off
307307
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
308308

309309
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:
310+
anything you set: a name written wholly in Han or Hangul — or one
311+
mixing kanji with kana — is assigned family-first (``script_orders``),
312+
and an unspaced hangul name is split into surname and given name
313+
against the shipped Korean census list (``segment_scripts``).
314+
:ref:`east-asian-names` explains the naming conventions both rest on —
315+
this section is how to switch them off, which you can do separately:
316316

317317
.. doctest::
318318

@@ -345,6 +345,18 @@ Chinese surnames are deliberately absent from that default set,
345345
because splitting Han text requires knowing Chinese from Japanese;
346346
:doc:`locales` covers the opt-in ``zh`` pack that supplies them.
347347

348+
The Japanese behaviors ride these same two fields, so they need no
349+
switches of their own: ``script_orders={}`` clears the kana-licensed
350+
entry along with the Han and Hangul ones, and ``segment_scripts=()``
351+
deactivates every script at once, which also stops a parser consulting
352+
whatever segmenter it was given. The segmenter has an off-switch as
353+
well — ``Parser(segmenter=None)``, which is the default; see
354+
:ref:`segmenter-contract` for what one is expected to do with text it
355+
does not handle. One Japanese behavior is not a policy field at all:
356+
the katakana middle dot ・ separates tokens the way a space does,
357+
decided in tokenization, so it applies however these two fields are
358+
set.
359+
348360
.. note::
349361

350362
Both fields are annotated with their canonical *storage* type

docs/locales.rst

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,18 @@ background (covered fully under :ref:`east-asian-names` in
6161
:doc:`usage`): Chinese, Japanese, and Korean names put the family name
6262
first in native script and are usually written with no space between
6363
the parts. Both defaults follow from facts the script alone
64-
establishes. A name written wholly in Han or Hangul is assigned
64+
establishes. A name written wholly in Han or Hangul — or one mixing
65+
kanji with kana, a combination only Japanese produces — is assigned
6566
family-first, because every language written in those scripts orders
66-
names that way no language guess is involved. An unspaced hangul
67+
names that way; no language guess is involved. An unspaced hangul
6768
name is additionally split into surname and given name, because hangul
6869
is written by nothing but Korean and Korean surnames are a closed
6970
census set that ships as default vocabulary. Splitting an unspaced
7071
*Han* name is the one behavior the script cannot license — the same
7172
characters could be a Chinese or a Japanese name, and a Chinese
7273
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.
74+
default: the ``zh`` and ``ja`` packs below turn it on for data whose
75+
language you can declare.
7576

7677
A pack is for something different: a *structural* rule, like reordering
7778
a patronymic, that vocabulary alone can't express.
@@ -109,7 +110,7 @@ takes:
109110
.. doctest::
110111

111112
>>> locales.available()
112-
('ru', 'tr_az', 'zh')
113+
('ja', 'ru', 'tr_az', 'zh')
113114
>>> locales.get("ru") is locales.RU
114115
True
115116

@@ -123,6 +124,14 @@ parsing, equivalent to ``parser_for(locales.get("ru"))``.
123124

124125
* - Code
125126
- Turns on
127+
* - ``ja``
128+
- Japanese segmentation — activates division for unspaced
129+
Japanese names, which needs a segmenter to act: install
130+
``nameparser[ja]`` and pass
131+
``parser_for(locales.JA, segmenter=locales.ja_segmenter())``
132+
(``山田太郎`` → family ``山田``, given ``太郎``). The pack ships
133+
no surname list, because no list divides a kanji name, and no
134+
order: a Japanese name already reads family-first by default.
126135
* - ``ru``
127136
- East Slavic patronymic order — detects a formal
128137
given/patronymic/family shape (Cyrillic and transliterated
@@ -139,9 +148,9 @@ parsing, equivalent to ``parser_for(locales.get("ru"))``.
139148
name order: native-script Han already reads family-first
140149
without a pack.
141150

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
151+
``ja``, ``ru`` and ``tr_az`` are policy-only — they carry no vocabulary
152+
of their own. ``zh`` is both halves at once: a surname list, plus the
153+
one policy field that turns segmentation on for the script it covers. See
145154
:doc:`concepts` for how that split (language vocabulary vs. behavior)
146155
is drawn, and `Contributing a pack to nameparser`_ for which half a
147156
new naming rule belongs in.
@@ -167,6 +176,47 @@ new naming rule belongs in.
167176
mixes traditions, parse the subsets separately with different
168177
parsers rather than enabling a pack over all of it.
169178

179+
.. _segmenter-contract:
180+
181+
Segmenters
182+
-----------
183+
184+
``ja`` is policy-only in a second sense: it turns division on for
185+
Japanese text without supplying anything to divide with. That job goes
186+
to a **segmenter**, which is passed to
187+
:func:`~nameparser.parser_for` rather than carried by the pack — a
188+
:class:`~nameparser.Locale` is pure data, and a third-party callable is
189+
neither pure nor data. :func:`~nameparser.locales.ja_segmenter` is the
190+
shipped one; writing your own is worth it for any script whose
191+
divisions you know better than a surname list does.
192+
193+
A :data:`~nameparser.Segmenter` is any callable taking a token's text
194+
and returning a :class:`~nameparser.Segmentation` — the interior
195+
offsets to cut at, plus how confident you are — or ``None`` to decline,
196+
leaving the token whole. Declining is the load-bearing half of the
197+
contract, because ``segment_scripts`` unions across packs: your
198+
segmenter is offered every token of every activated script, not only
199+
the ones its own pack turned on. Recognize the text you can actually
200+
read and return ``None`` for the rest, rather than answering for a
201+
script you never meant to handle. Exceptions are the one thing that
202+
does not stay inside the parse — a segmenter is your code, so its
203+
errors propagate out of ``parse()`` instead of being absorbed as
204+
content errors.
205+
206+
The pack half of that arrangement carries no words at all, which makes
207+
it the shortest kind of pack there is:
208+
209+
.. doctest::
210+
211+
>>> from nameparser import Lexicon, Locale, PolicyPatch, Script
212+
>>> mine = Locale(code="myscript", lexicon=Lexicon.empty(),
213+
... policy=PolicyPatch(
214+
... segment_scripts=frozenset({Script.HAN})))
215+
216+
``nameparser/locales/ja.py`` is the shipped example of exactly that
217+
shape: activation is the pack's entire contribution, and a pack
218+
applied without a segmenter simply divides nothing.
219+
170220
Creating your own Locale
171221
-------------------------
172222

@@ -237,11 +287,14 @@ by ``tests/v2/test_locales.py``:
237287
needs at least one name exercising every alternation branch of every
238288
regex it defines — ``test_rotators_cover_every_marker_branch`` fails
239289
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.
290+
(``zh``, ``ja``) has no branches to sweep and drops out of that
291+
test, so its rotators have to carry the same weight by hand: the
292+
unspaced names the pack must split, one per shape of the vocabulary
293+
it ships — single surname, compound surname, and any spelling
294+
variant it means to cover. A pack that ships no vocabulary lists
295+
the shapes its *segmenter* must divide instead, and marks the
296+
rotator tests to skip when the optional dependency is absent, so
297+
the contract tests still run everywhere.
245298
#. Keep the non-interference gate green over the shared corpus plus
246299
your rotators: every name the packed parser parses differently from
247300
the default must be one your ``DEVIATES`` predicate flags — no
@@ -256,16 +309,17 @@ by ``tests/v2/test_locales.py``:
256309
language its script does not* — a Chinese surname list, which
257310
silently mangles the Japanese names written in the same characters
258311
— 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.
312+
``ja``, ``ru`` and ``tr_az`` need no vocabulary at all and ship an
313+
empty :class:`~nameparser.Lexicon`; ``nameparser/locales/zh.py`` is
314+
the template for one that does.
262315
#. Curate vocabulary conservatively, the same rule as
263316
:doc:`customize`: when you're unsure whether a word or a marker
264317
belongs, leave it out.
265318

266319
``nameparser/locales/ru.py`` is the reference implementation for a
267320
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>`_
321+
vocabulary, and ``nameparser/locales/ja.py`` for one whose whole
322+
contribution is turning a stage on. Packs still in progress are
323+
tracked in issue `#146
324+
<https://github.com/derek73/python-nameparser/issues/146>`_
271325
(Vietnamese).

docs/migrate.rst

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,11 @@ 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.
342342

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.
343+
2.1 adds three more, and unlike most of the 2.0 API these do reach
344+
``HumanName``: a name written in East Asian script is read
345+
family-first, an unspaced Korean name is split into surname and given
346+
name, and the katakana middle dot separates tokens the way a space
347+
does.
347348

348349
.. doctest::
349350

@@ -377,7 +378,58 @@ Both were ``first`` under 1.4. If you feed unspaced CJK through
377378
reach you, and it is silent — the string is intact, just in the other
378379
field.
379380

381+
Japanese kana carries the same order rule, which widens both shapes
382+
past the wholly-Han text described above. A name that mixes kanji with
383+
hiragana or katakana is read family-first, and a lone kana-bearing
384+
token moves from ``first`` to ``last`` exactly as ``毛泽东`` does:
385+
386+
.. doctest::
387+
388+
>>> HumanName("高橋 みなみ").last, HumanName("高橋 みなみ").first
389+
('高橋', 'みなみ')
390+
>>> HumanName("山田 エミ").last
391+
'山田'
392+
>>> HumanName("高橋みなみ").last, HumanName("高橋みなみ").first
393+
('高橋みなみ', '')
394+
395+
1.4 read the spaced ones as ``first="高橋"``/``last="みなみ"`` and
396+
``first="山田"``/``last="エミ"``, and put the unspaced one whole in
397+
``first``. The spaced shapes change what the name renders as too:
398+
``str(HumanName("高橋 みなみ"))`` was ``"高橋 みなみ"`` and is now
399+
``"みなみ 高橋"``. A name written *wholly* in katakana is deliberately
400+
left alone — it is usually a transcribed foreign name already in
401+
given-first order — so ``HumanName("マイケル ジャクソン")`` reads
402+
``first="マイケル"``/``last="ジャクソン"`` on both versions.
403+
404+
One more shape changes for a different reason: the katakana middle dot
405+
````, which divides the parts of such a transcription, is now a token
406+
separator rather than an ordinary character. 1.4 saw one token and put
407+
it in ``first``; 2.1 sees two:
408+
409+
.. doctest::
410+
411+
>>> HumanName("マイケル・ジャクソン").first
412+
'マイケル'
413+
>>> HumanName("マイケル・ジャクソン").last
414+
'ジャクソン'
415+
>>> HumanName("高橋・一郎").last, HumanName("高橋・一郎").first
416+
('高橋', '一郎')
417+
418+
The two divide the same way and land in opposite fields, because the
419+
katakana pair keeps its source order while the kanji pair takes the
420+
family-first rule. Separating also changes the rendered string, the
421+
same way the Korean split does: the dot comes back as a space, so
422+
``str(HumanName("マイケル・ジャクソン"))`` was ``"マイケル・ジャクソン"``
423+
and is now ``"マイケル ジャクソン"``. That reaches delimited content
424+
too — the nickname in ``"山田 太郎 (マイケル・ジャクソン)"`` was
425+
``"マイケル・ジャクソン"`` under 1.4 and is ``"マイケル ジャクソン"``
426+
now.
427+
380428
``Constants`` has no switch for any of this — the v1 configuration
381429
surface is frozen for 2.x — so the way out is the 2.0 API:
382430
``Parser(policy=Policy(script_orders={}, segment_scripts=()))``
383-
restores 1.4's reading of all three shapes.
431+
restores 1.4's reading of every shape above that turns on order or
432+
splitting. The middle dot is the one exception: it is decided in
433+
tokenization rather than by policy, so a name written with one still
434+
divides at the dot, and still renders with a space, whatever those two
435+
fields are set to.

docs/modules.rst

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ Parsing
1414

1515
.. autofunction:: nameparser.parser_for
1616

17+
.. autoclass:: nameparser.Segmentation
18+
:members:
19+
20+
.. py:data:: nameparser.Segmenter
21+
:value: Callable[[str], Segmentation | None]
22+
23+
The type of the optional ``Parser(segmenter=...)`` hook: a callable
24+
given one token's text, returning a
25+
:class:`~nameparser.Segmentation` that divides it, or ``None`` to
26+
decline and leave it whole. An alias, not a class — any callable of
27+
that shape qualifies, and nothing needs to be subclassed or
28+
registered. It is consulted only for tokens whose script is listed
29+
in :attr:`Policy.segment_scripts
30+
<nameparser.Policy.segment_scripts>`, and only where the surname
31+
vocabulary declined first; see :ref:`segmenter-contract` for what a
32+
segmenter owes its caller.
33+
:func:`~nameparser.locales.ja_segmenter` is the shipped
34+
implementation.
35+
1736
Results
1837
~~~~~~~
1938

@@ -106,10 +125,15 @@ because only these three orders have defined assignment semantics.
106125
e.g. Vietnamese full-name order.
107126

108127
.. py:data:: nameparser.DEFAULT_SCRIPT_ORDERS
109-
:value: ((Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST))
128+
:value: ((Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST), (Script.HIRAGANA, FAMILY_FIRST))
110129

111130
The default :attr:`~nameparser.Policy.script_orders` table: a name
112-
written wholly in Han or Hangul reads family-first. A matching
131+
written wholly in Han or Hangul reads family-first, and so does a
132+
Japanese name mixing kanji with kana, which resolves to the
133+
``HIRAGANA`` entry whichever of the two syllabaries it actually
134+
uses — that member is the license's carrier key, not a claim about
135+
the characters present. A name written wholly in katakana has no
136+
entry on purpose and stays positional. A matching
113137
entry in this table takes precedence over ``name_order``, including
114138
a ``name_order`` you set explicitly — ``name_order`` governs only
115139
the names no entry matches. The values are drawn from the same
@@ -147,6 +171,8 @@ Locales
147171
.. automodule:: nameparser.locales
148172
:members: get, available
149173

174+
.. autofunction:: nameparser.locales.ja_segmenter
175+
150176
1.x compatibility layer
151177
------------------------
152178

0 commit comments

Comments
 (0)