Skip to content

Commit a102c86

Browse files
derek73claude
andcommitted
Simplify and speed up the #271 hot paths
The four #271 additions all sat on paths every parse walks, and three of them paid for shapes measurement did not support. single_script's per-char all/any sweep was written on the _EMOJI_RANGES precedent, on the theory that a range test needs no regex. At token scale that is backwards: a compiled character class per script runs 5x faster on ordinary CJK tokens and 35x on long ones. The integer table stays the single source of truth -- the patterns are DERIVED from it, in its own key order, so the first-covering-entry rule still describes what single_script does. script_segment now returns early on a wholly-ASCII original. Spans index the original exactly, so an ASCII original has only ASCII tokens and no script's ranges can contain any of them: the stage was building a lexicon lookup and a generator per Latin parse to prove it. _effective_order joined each piece's tokens into a throwaway string and built a set of results before asking whether the set had one member. WorkToken text is never empty, so "the piece is wholly one script" is exactly "every token in it is" -- a per-token walk with early exit says the same thing and stops at the first token that settles it. _policy.py loses two near-duplicate wrap-and-rethrow blocks (the probe-vs-consume rule now lives once, on _require_iterable, which takes the field's own phrasing as a parameter), a vestigial tuple() materialization, and PolicyPatch.__post_init__'s 77-line inline script_orders block, which becomes a named helper that always materializes. Error texts are unchanged; the one behavior nuance is invisible from outside -- a re-iterable input with a malformed entry is now stored as a materialized tuple rather than the original container, and Policy quotes entries, never containers. Tests: the Korean-surname range check reads the shared table instead of a second hand-copy of 0xAC00/0xD7A3, one comment that promised a stage as future work is reworded now that it shipped, and the zh anti-dedupe note covers the case-table overlap it had grown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 50f722c commit a102c86

8 files changed

Lines changed: 123 additions & 147 deletions

File tree

nameparser/_pipeline/_assign.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from nameparser._pipeline._state import (
3737
ParseState, PendingAmbiguity, Structure, WorkToken,
3838
)
39-
from nameparser._policy import Policy
39+
from nameparser._policy import Policy, Script
4040
from nameparser._types import AmbiguityKind, Role
4141

4242
# Ported verbatim from v1 (nameparser/config/regexes.py
@@ -90,15 +90,22 @@ def _effective_order(policy: Policy,
9090
return policy.name_order
9191
# ONE script for the whole name, not "the entries all agree": two
9292
# scripts that both read family-first still fall back, because a
93-
# Han+Hangul name is not written in either tradition.
94-
found = {single_script("".join(tokens[i].text for i in piece))
95-
for piece in pieces}
96-
if len(found) == 1:
97-
script = found.pop() # None (no single script) matches
98-
for entry_script, order in policy.script_orders: # no entry
99-
if entry_script is script:
100-
return order
101-
return policy.name_order
93+
# Han+Hangul name is not written in either tradition. Per token
94+
# rather than per joined piece -- WorkToken text is never empty, so
95+
# "the piece is wholly one script" is "every token in it is".
96+
found: Script | None = None
97+
for piece in pieces:
98+
for i in piece:
99+
script = single_script(tokens[i].text)
100+
if script is None:
101+
# Latin, mixed, or a script with no entry: never a key
102+
return policy.name_order
103+
if found is None:
104+
found = script
105+
elif script is not found:
106+
return policy.name_order
107+
return next((order for s, order in policy.script_orders if s is found),
108+
policy.name_order)
102109

103110

104111
def _name_positions(order: tuple[Role, Role, Role],

nameparser/_pipeline/_script_segment.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ def _longest_entry(surnames: frozenset[str]) -> int:
8686

8787

8888
def script_segment(state: ParseState) -> ParseState:
89+
if state.original.isascii():
90+
# spans index the original exactly (the anti-#100 invariant),
91+
# so an ASCII original has only ASCII tokens: nothing here is
92+
# in any script's ranges
93+
return state
8994
scripts = state.policy.segment_scripts
9095
surnames = state.lexicon.surnames
9196
if not scripts or not surnames or not state.segments:

nameparser/_pipeline/_vocab.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@
2929
PH = re.compile(r"^ph\.?$", re.IGNORECASE)
3030
D = re.compile(r"^d\.?$", re.IGNORECASE)
3131

32-
# Codepoint ranges per Script (#271): integer ranges following the
33-
# _EMOJI_RANGES precedent in _tokenize.py -- the per-char test needs
34-
# no regex. HAN: the URO plus Extension A, the compatibility block,
32+
# Codepoint ranges per Script (#271). This integer table is the single
33+
# source of truth for what a script covers; _SCRIPT_PATTERNS below
34+
# DERIVES the match engine from it. (The sweep here was first written
35+
# per-char on the _EMOJI_RANGES precedent in _tokenize.py, on the
36+
# theory that a range test needs no regex; measured at token scale the
37+
# compiled regex wins by 3-9x, and by 89x on long tokens.)
38+
# HAN: the URO plus Extension A, the compatibility block,
3539
# and the supplementary-plane block (Ext B-I + CJK Compat Ideographs
3640
# Supplement, 0x20000-0x323AF) -- rare surnames are the biggest real
3741
# source of supplementary-plane hanzi in personal names (e.g. 𠮷田's
@@ -51,6 +55,16 @@
5155
Script.HANGUL: ((0xAC00, 0xD7A3),),
5256
}
5357

58+
# Derived, never hand-written: one character class per script, in the
59+
# table's own key order (so the FIRST-covering-entry rule above still
60+
# describes what single_script does).
61+
_SCRIPT_PATTERNS: dict[Script, re.Pattern[str]] = {
62+
script: re.compile(
63+
"[" + "".join(f"\\U{lo:08x}-\\U{hi:08x}" for lo, hi in ranges)
64+
+ "]+")
65+
for script, ranges in _SCRIPT_RANGES.items()
66+
}
67+
5468

5569
def is_initial(text: str) -> bool:
5670
"""'A.' / 'j.' / bare capital -- v1's is_an_initial."""
@@ -153,14 +167,13 @@ def single_script(text: str) -> Script | None:
153167
None (mixed-script text has no well-defined convention to apply;
154168
the caller falls back to the positional default)."""
155169
if not text:
156-
return None # all() is vacuously true; "" belongs to no script
170+
return None # the + below needs one char; "" belongs to no script
157171
if text.isascii():
158172
# every _SCRIPT_RANGES entry is non-ASCII (lowest today is
159-
# U+3400): skip the range sweep for the overwhelmingly common
173+
# U+3400): skip the patterns for the overwhelmingly common
160174
# Latin token (the _tokenize._ignorable ASCII-floor precedent)
161175
return None
162-
for script, ranges in _SCRIPT_RANGES.items():
163-
if all(any(lo <= ord(c) <= hi for lo, hi in ranges)
164-
for c in text):
176+
for script, pattern in _SCRIPT_PATTERNS.items():
177+
if pattern.fullmatch(text):
165178
return script
166179
return None

nameparser/_policy.py

Lines changed: 61 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,28 @@ def _reject_str_and_mapping(value: object, field_name: str) -> None:
203203
)
204204

205205

206-
def _require_iterable(value: Iterable[Any], field_name: str) -> Iterable[Any]:
207-
# Probe with iter() so a non-iterable value (an int, a bool, ...)
208-
# raises a message naming the field, matching the treatment
209-
# patronymic_rules already gets, instead of a bare "'int' object is
210-
# not iterable" surfacing from whatever tuple()/frozenset() call
211-
# happens to run first.
206+
def _require_iterable(value: Iterable[Any], field_name: str,
207+
expected: str = "an iterable") -> Iterable[Any]:
208+
"""Probe a value's iterability and return its iterator, raising a
209+
TypeError naming the field if it has none. `expected` carries the
210+
field's own phrasing ("a mapping of Script to order"); the default
211+
suits every plain iterable field.
212+
213+
Probing with iter() is what makes the message possible: a
214+
non-iterable (an int, a bool, ...) is named here, matching the
215+
treatment patronymic_rules already gets, instead of a bare "'int'
216+
object is not iterable" surfacing from whatever tuple()/frozenset()
217+
happens to run first. It is ALSO the reason callers consume the
218+
returned iterator OUTSIDE any try of their own: an exception raised
219+
inside a caller's generator while it is being consumed is the
220+
caller's own error, and must propagate untouched rather than be
221+
rewritten as a shape complaint about the field.
222+
"""
212223
try:
213224
return iter(value)
214225
except TypeError:
215226
raise TypeError(
216-
f"{field_name} must be an iterable, got {value!r}"
227+
f"{field_name} must be {expected}, got {value!r}"
217228
) from None
218229

219230

@@ -284,22 +295,11 @@ def _validated_script_orders(
284295
f"e.g. raw.decode('utf-8')"
285296
)
286297
raw = value.items() if isinstance(value, Mapping) else value
287-
try:
288-
# Probe with iter() only (mirrors the patronymic_rules probe in
289-
# Policy.__post_init__, and _validated_segment_scripts below):
290-
# a genuine non-iterable is caught here and gets the curated
291-
# message below, while an exception raised INSIDE a caller's
292-
# generator during consumption (the tuple() below) must
293-
# propagate untouched, not be rewritten as "not a mapping".
294-
raw_iter = _require_iterable(raw, "script_orders") # type: ignore[arg-type]
295-
except TypeError:
296-
raise TypeError(
297-
f"script_orders must be a mapping of Script to order, "
298-
f"got {value!r}"
299-
) from None
300-
entries: tuple[Any, ...] = tuple(raw_iter)
298+
raw_iter = _require_iterable(
299+
raw, "script_orders", # type: ignore[arg-type]
300+
"a mapping of Script to order")
301301
canonical: dict[Script, tuple[Role, Role, Role]] = {}
302-
for entry in entries:
302+
for entry in raw_iter:
303303
try:
304304
key, order = entry
305305
except (TypeError, ValueError):
@@ -320,18 +320,9 @@ def _validated_segment_scripts(value: object) -> frozenset[Script]:
320320
their string values), coerced via _validated_script so the
321321
unknown-script wording stays single-sourced."""
322322
_reject_str_and_mapping(value, "segment_scripts")
323-
# Probe with iter() only (mirrors the patronymic_rules probe in
324-
# Policy.__post_init__): a genuine non-iterable is caught here and
325-
# gets the curated message below, while an exception raised INSIDE
326-
# a caller's generator during consumption (the frozenset() below)
327-
# must propagate untouched, not be rewritten as "not an iterable".
328-
try:
329-
script_iter = _require_iterable(value, "segment_scripts") # type: ignore[arg-type]
330-
except TypeError:
331-
raise TypeError(
332-
f"segment_scripts must be an iterable of Script members, "
333-
f"got {value!r}"
334-
) from None
323+
script_iter = _require_iterable(
324+
value, "segment_scripts", # type: ignore[arg-type]
325+
"an iterable of Script members")
335326
return frozenset(_validated_script(s) for s in script_iter)
336327

337328

@@ -359,6 +350,35 @@ def _canonical_script_pair(pair: Iterable[Any]) -> tuple[Any, ...]:
359350
return out # non-iterable order value
360351

361352

353+
def _canonical_patch_script_orders(value: object) -> object:
354+
"""Canonicalize a PolicyPatch.script_orders value for hashability
355+
without validating it: malformed shapes are stored so Policy can
356+
quote them at apply time; a caller-generator's own exception
357+
propagates from the UNGUARDED materialization below (deferring is
358+
impossible once a one-shot iterator is consumed)."""
359+
# Excluded HERE rather than delegated: a string's elements are
360+
# themselves tuple-izable, so no TypeError ever fires to signal
361+
# "leave this alone" -- "han" would shred to (("h",), ("a",),
362+
# ("n",)) and Policy's bare-string message would have nothing left
363+
# to quote. bytes shred the same way, into ints.
364+
if isinstance(value, (str, bytes, bytearray, memoryview)):
365+
return value # deferred whole: Policy's guards quote it
366+
# Any, not object: a patch defers validation, so anything a caller
367+
# wrote can arrive here, and the probe below is precisely the
368+
# runtime question mypy has no way to answer statically.
369+
pairs: Any = value.items() if isinstance(value, Mapping) else value
370+
try:
371+
pairs_iter = iter(pairs) # probe only
372+
except TypeError:
373+
return value # non-iterable: defer to apply
374+
items = tuple(pairs_iter) # UNGUARDED: caller errors propagate
375+
try:
376+
return tuple(map(_canonical_script_pair, items))
377+
except TypeError:
378+
return items # malformed entry: materialized, so
379+
# Policy can still re-iterate + quote
380+
381+
362382
@dataclass(frozen=True, slots=True)
363383
class Policy:
364384
"""The behavior switches a parser runs with: name order,
@@ -627,90 +647,13 @@ def __post_init__(self) -> None:
627647
# from a {Script: order} dict (or a list of pairs) must already
628648
# be hashable, since a Locale holds it -- and hashable all the
629649
# way down, hence _canonical_script_pair. Validation still
630-
# belongs to Policy at apply time, so a shape tuple() cannot
631-
# digest is left exactly as written for it to report.
632-
# The str case must be excluded HERE rather than delegated to
633-
# _canonical_script_pair: a string's elements are themselves
634-
# tuple-izable, so no TypeError ever fires to signal "leave this
635-
# alone" -- "han" would shred to (("h",), ("a",), ("n",)) and
636-
# Policy's bare-string message would have nothing left to quote.
637-
if self.script_orders is not UNSET and not isinstance(
638-
self.script_orders, str):
639-
raw = self.script_orders
640-
# Mapping.items() is always iterable, so it needs no probe;
641-
# only the non-Mapping branch can fail the iter() check below.
642-
pairs = raw.items() if isinstance(raw, Mapping) else raw
643-
# Four outcomes share this block, and only one of them
644-
# should propagate immediately:
645-
# 1. raw itself isn't iterable at all (script_orders=5) --
646-
# caught by the iter() probe. Shape problem, defers to
647-
# apply time, where Policy's own guard raises the
648-
# curated message ("Policy validates ... at apply").
649-
# 2. an already-yielded PAIR has a bad shape (a bare int
650-
# from a shredded bytes buffer, e.g. b"han" iterating
651-
# to 104) -- caught around _canonical_script_pair
652-
# below. Also a shape problem: abort the whole
653-
# conversion and leave script_orders as a RE-ITERABLE
654-
# value (see case 4) Policy's curated message (e.g. the
655-
# decode-first bytes hint, or the bad-pair message) can
656-
# still quote at apply time.
657-
# 3. the caller's OWN exception, raised by their
658-
# generator while it is being consumed -- this is not
659-
# a shape problem this guard exists to catch, and
660-
# deferral is impossible anyway: by the time
661-
# apply_patch reaches Policy.__post_init__, the same
662-
# generator is already exhausted, so catching this
663-
# here would lose the error for good rather than
664-
# merely delay it. Consuming a one-shot iterator
665-
# therefore runs UNGUARDED (case 4), so this
666-
# propagates on its own terms.
667-
# 4. a ONE-SHOT iterator (iter(x) is x -- generators,
668-
# map/zip/filter objects, ...) cannot be safely
669-
# re-iterated once consumed. Deferring case 2 by
670-
# leaving it as originally written -- fine for
671-
# re-iterable inputs (bytes, list, dict, tuple) -- would
672-
# leave Policy re-validating an EXHAUSTED generator at
673-
# apply time: iterating it again silently yields
674-
# nothing, storing () ("opt out of per-script
675-
# ordering") and dropping the Han/Hangul family-first
676-
# defaults with NO error anywhere. So a one-shot input
677-
# is eagerly materialized into a tuple first (case 3's
678-
# unguarded consumption), and case 2's deferral stores
679-
# THAT re-iterable tuple instead of the original,
680-
# exhausted generator.
681-
try:
682-
pairs_iter = iter(pairs)
683-
except TypeError:
684-
pairs_iter = None # non-iterable: deferred, case 1
685-
if pairs_iter is not None:
686-
# mypy sees pairs' declared type (tuple[...] | ItemsView)
687-
# as never identical to iter(pairs)'s Iterator type, but
688-
# at runtime pairs can be anything iterable a caller
689-
# wrote (a generator especially) -- the whole point of
690-
# this check.
691-
one_shot = pairs_iter is pairs # type: ignore[comparison-overlap]
692-
items: Iterable[Any] = tuple(pairs_iter) if one_shot else pairs
693-
canonical = []
694-
deferred = False
695-
for item in items:
696-
try:
697-
canonical.append(_canonical_script_pair(item))
698-
except TypeError:
699-
deferred = True # bad pair shape: case 2
700-
break
701-
if not deferred:
702-
object.__setattr__(
703-
self, "script_orders", tuple(canonical))
704-
elif one_shot:
705-
# items is already the materialized (re-iterable)
706-
# tuple built above -- store it so Policy can
707-
# re-examine and quote it at apply time, instead of
708-
# the original, now-exhausted generator.
709-
object.__setattr__(self, "script_orders", items)
710-
# else: re-iterable input (bytes/list/dict/tuple/...) is
711-
# left exactly as originally written; Policy can freely
712-
# re-iterate it at apply time and quote the caller's
713-
# value there -- unchanged from the prior behavior.
650+
# belongs to Policy at apply time, so a shape the canonicalizer
651+
# cannot digest is left for it to report; the one-shot and
652+
# malformed-entry cases are _canonical_patch_script_orders'.
653+
if self.script_orders is not UNSET:
654+
object.__setattr__(
655+
self, "script_orders",
656+
_canonical_patch_script_orders(self.script_orders))
714657
for f in dataclasses.fields(self):
715658
if f.metadata.get("compose") != "union":
716659
continue

tests/v2/test_lexicon.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from nameparser._lexicon import (
1010
Lexicon, _VOCAB_FIELDS, _default_lexicon, _normalize, _title_key,
1111
)
12+
from nameparser._pipeline._vocab import _SCRIPT_RANGES
13+
from nameparser._policy import Script
1214

1315

1416
def test_entries_are_normalized_at_construction() -> None:
@@ -571,6 +573,8 @@ def test_default_lexicon_ships_korean_surnames_only() -> None:
571573
assert "김" in lex.surnames and "남궁" in lex.surnames
572574
# Han surnames are locales.ZH's cargo (segmentation opt-in), so
573575
# the DEFAULT set is wholly hangul -- structural check, not
574-
# content-pinning
575-
assert all(all(0xAC00 <= ord(c) <= 0xD7A3 for c in s)
576+
# content-pinning. The spans come from the shared table rather than
577+
# a second hand-copy of 0xAC00/0xD7A3 (test_locales.py's precedent).
578+
hangul = _SCRIPT_RANGES[Script.HANGUL]
579+
assert all(all(any(lo <= ord(c) <= hi for lo, hi in hangul) for c in s)
576580
and 1 <= len(s) <= 2 for s in lex.surnames)

tests/v2/test_locales.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,14 @@ def test_zh_longest_match_prefers_compound_over_single_surname() -> None:
131131
assert p.parse("夏雨").ambiguities == ()
132132

133133

134-
# 毛泽东/欧阳修/夏侯惇/萧红 appear in _ROTATORS["zh"] below as well.
135-
# Not deduplicated on purpose: the rotator tests assert only THAT the
136-
# packed parse differs from the default and that DEVIATES declares it
137-
# -- they never look at a field value, so the readings above are
138-
# pinned nowhere else.
134+
# 毛泽东/欧阳修/夏侯惇/萧红 appear in _ROTATORS["zh"] below, and 毛泽东/
135+
# 夏侯惇 in cases.py's zh rows, as well. Not deduplicated in either
136+
# direction, on purpose: the rotator tests assert only THAT the packed
137+
# parse differs from the default and that DEVIATES declares it -- they
138+
# never look at a field value -- and the case rows reach the same
139+
# packed parser only through the shared-table runners
140+
# (test_cases.py/test_facade_cases.py). These tests are where the zh
141+
# readings are pinned at unit level, against the stage directly.
139142

140143

141144
def test_zh_composes_with_korean_defaults() -> None:

tests/v2/test_parser.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -445,9 +445,10 @@ def test_wholly_cjk_names_read_family_first_by_default() -> None:
445445
assert (n.family, n.given) == ("毛", "泽东")
446446
n = parse("김 민준")
447447
assert (n.family, n.given) == ("김", "민준")
448-
# a lone wholly-CJK token takes the script order's first role
449-
assert parse("毛泽东").family == "毛泽东" # unspaced: split arrives
450-
# with Task 4's stage
448+
# a lone wholly-CJK token takes the script order's first role: Han
449+
# segmentation is opt-in (locales.ZH), so the default parser leaves
450+
# this one token whole and reads it as the family name
451+
assert parse("毛泽东").family == "毛泽东"
451452

452453

453454
def test_latin_names_are_untouched_by_script_orders() -> None:

tests/v2/test_policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ def test_segment_scripts_rejects_unknown_script() -> None:
545545

546546
def test_segment_scripts_rejects_non_iterable_with_curated_message() -> None:
547547
# parallel to patronymic_rules: name the expected shape rather than
548-
# surfacing _require_iterable's generic "must be an iterable" text
548+
# letting _require_iterable's default "an iterable" phrasing stand
549549
with pytest.raises(TypeError,
550550
match="segment_scripts must be an iterable of "
551551
"Script members, got True"):

0 commit comments

Comments
 (0)