Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_policy.py: 59%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

249 statements  

1"""Immutable behavior configuration for the 2.0 API. 

2 

3Layering: imports nameparser._types only (enforced by 

4tests/v2/test_layering.py). 

5""" 

6from __future__ import annotations 

7 

8import dataclasses 

9import re 

10from collections.abc import Callable, Iterable, Mapping 

11from dataclasses import dataclass, field 

12from enum import Enum, StrEnum, auto 

13from typing import Any 

14 

15from nameparser._types import Role, _guarded_getstate, _guarded_setstate 

16 

17 

18class PatronymicRule(StrEnum): 

19 """Stable rule names (API); implementations live in the pipeline. 

20 Enable via ``Policy(patronymic_rules={...})`` or, more commonly, a 

21 locale pack (:mod:`nameparser.locales`).""" 

22 

23 #: East Slavic formal order: "Sidorov Ivan Petrovich" 

24 #: (family, given, patronymic) is detected by the patronymic 

25 #: ending and reordered. Enabled by locales.RU. 

26 EAST_SLAVIC = "east-slavic" 

27 #: Turkic patronymic markers: a standalone "oglu"/"qizi"/"kyzy" 

28 #: (etc.) binds to the preceding name as a patronymic. Enabled by 

29 #: locales.TR_AZ. 

30 TURKIC = "turkic" 

31 

32 

33class Script(StrEnum): 

34 """Writing systems the parser can key SCRIPT-CONDITIONAL behavior 

35 on: per-script name order (``Policy.script_orders``) and 

36 unspaced-name segmentation (``Policy.segment_scripts``). The rule 

37 that admits these (amendment 2026-07-27): script-conditional 

38 behavior only where the script itself determines the convention -- 

39 Latin-script input is never affected. The codepoint table backing 

40 these members is internal.""" 

41 

42 #: Chinese Hanzi -- and Japanese Kanji: a pure-Han string cannot 

43 #: say which language it is, which is fine for ORDER (both write 

44 #: family-first natively) and exactly why Han SEGMENTATION is 

45 #: opt-in, per language: locales.ZH brings the Chinese surname 

46 #: list, locales.JA activates the same stage for a pluggable 

47 #: segmenter to divide kanji names with. 

48 HAN = "han" 

49 #: Korean Hangul (precomposed syllables). Unambiguously Korean. 

50 HANGUL = "hangul" 

51 #: Japanese hiragana. Never transcribes foreign names, so a mixed 

52 #: kanji+kana token (高橋みなみ) is Japanese and resolves HERE -- 

53 #: this member is the carrier key in script_orders/segment_scripts. 

54 HIRAGANA = "hiragana" 

55 #: Japanese katakana. A PURE-katakana token is predominantly a 

56 #: transcribed foreign name in its original order (マイケル), so 

57 #: no default behavior keys on this member; it exists so the 

58 #: classifier can name what it deliberately declines. 

59 KATAKANA = "katakana" 

60 

61 

62# Codepoint ranges per Script (#271). This integer table is the single 

63# source of truth for what a script covers; every matcher DERIVES from 

64# it -- _pipeline/_vocab.py compiles its per-script patterns from it, 

65# and it is importable from the pipeline and the locale packs alike, 

66# so the packs' predicates build on it too, through _script_matcher 

67# below (the table lives here rather than in the pipeline because 

68# packs must not import the pipeline). 

69# HAN: the ideographic iteration mark U+3005 and the shime mark 

70# U+3006, the URO plus Extension A, the compatibility block, and the 

71# supplementary-plane block 

72# (Ext B-I + CJK Compat Ideographs Supplement, 0x20000-0x323AF) -- 

73# rare surnames are the biggest real source of supplementary-plane 

74# hanzi in personal names (e.g. 𠮷田's 𠮷, U+20BB7), so leaving them 

75# out silently mis-orders those names; unassigned gaps inside the span 

76# are harmless, since no real name contains an unassigned codepoint. 

77# U+3005 々 is the block-vs-Script case, running the OPPOSITE way to 

78# U+30FB below: 々 already IS Script=Han under UAX #24 (Scripts.txt 

79# reads `3005 ; Han`), but it sits in CJK Symbols and Punctuation, 

80# outside every CJK ideograph block this table spans -- so a 

81# singleton entry was what a BLOCK table needed to reach a character 

82# the Script property would have classified correctly for free. It 

83# earns the reach: 々 repeats the preceding kanji and appears only 

84# inside Han-written names -- 佐々木 (Sasaki, a top-20 Japanese 

85# surname), 野々村, 奈々. Omitting it made 佐々木 a mixed-script token: 

86# the name reversed and never gated into segmentation. 

87# U+3006 〆 (the shime mark) extends that singleton to a two-codepoint 

88# span on a DIFFERENT justification: unlike 々, 〆 is Script=Common 

89# under UAX #24, so this is the table deliberately reaching PAST the 

90# Script property, not around a block boundary -- justified because 

91# within personal names 〆 appears solely in Japanese surnames (〆木 

92# Shimeki, 〆谷 Shimetani, 〆野) -- its other uses (the envelope 

93# closing mark, 〆切) never reach a name parser -- and it appears in 

94# no other script's names. 

95# HANGUL: precomposed syllables only -- modern Korean 

96# text never writes names as bare jamo. 

97# HIRAGANA/KATAKANA (#272): the two kana blocks, each in full. There 

98# IS a supplementary-plane kana repertoire (Kana Supplement, Kana 

99# Extended-A/B, Small Kana Extension, U+1AFF0-U+1B16F, a few hundred 

100# assigned codepoints -- no exact count here, it moves with the 

101# Unicode version) but none of it is WORTH chasing the way Han's astral 

102# block is: those codepoints are hentaigana and other archaic/ 

103# phonetic-extension forms no modern Japanese name uses, unlike 

104# supplementary Han, which real surnames genuinely need. The Katakana 

105# Phonetic Extensions block (U+31F0-U+31FF, 16 small katakana for Ainu 

106# transcription) is excluded for the same reason -- no modern Japanese 

107# personal name uses them. Halfwidth kana (U+FF65-U+FF9F, including 

108# the voiced/semi-voiced sound marks U+FF9E/U+FF9F) is likewise 

109# deliberately excluded -- legacy bank/CSV data uses it, but it is a 

110# separate normalization problem; #272 Task 2b's separator handling 

111# only touches the halfwidth DOT (U+FF65), not the rest of that block. 

112# This table classifies by Unicode BLOCK, not the UAX #24 Script 

113# property: U+30A0, U+30FB (the middle dot), and U+30FC (the 

114# prolonged sound mark) all carry Script=Common under UAX #24, and the 

115# four kana voicing marks U+3099-U+309C split two and two -- U+3099 

116# and U+309A are the COMBINING forms (Script=Inherited), U+309B and 

117# U+309C the spacing ones (Script=Common) -- yet every one of them is 

118# needed here, and block membership, not the Script property, is what 

119# puts them in range. The katakana block's upper end (U+30FF) takes in 

120# the middle dot U+30FB, kept rather than carved out for a smaller 

121# reason than it looks: tokenize (#272 Task 2b) turns U+30FB into a 

122# token separator, so no real parse shows the classifier a string 

123# containing one. It is kept so that a DIRECT whole-string call -- 

124# effective_script (_pipeline/_vocab.py) on "マイケル・ジャクソン", which 

125# the unit tests (tests/v2/pipeline/test_vocab.py) make -- still 

126# classifies instead of returning None. The ranges below must 

127# stay mutually disjoint: single_script (_pipeline/_vocab.py) returns 

128# the FIRST covering entry (dict iteration order), so an overlapping 

129# future script would make the result order-dependent instead of 

130# well-defined. 

131_SCRIPT_RANGES: dict[Script, tuple[tuple[int, int], ...]] = { 

132 Script.HAN: ((0x3005, 0x3006), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), 

133 (0xF900, 0xFAFF), (0x20000, 0x323AF)), 

134 Script.HANGUL: ((0xAC00, 0xD7A3),), 

135 Script.HIRAGANA: ((0x3040, 0x309F),), 

136 Script.KATAKANA: ((0x30A0, 0x30FF),), 

137} 

138 

139#: The Japanese repertoire: the three scripts Japanese names draw on. 

140#: The kana license (_pipeline/_vocab.py's effective_script), the ja 

141#: pack's DEVIATES, and the segmenter adapter's repertoire guard all 

142#: quantify over this one union (HANGUL simply omitted). 

143_JA_SCRIPTS = (Script.HAN, Script.HIRAGANA, Script.KATAKANA) 

144 

145#: Scripts whose characters cannot BE an initial. The criterion is 

146#: orthographic CONVENTION, not what a character is: does the writing 

147#: tradition abbreviate a given name to ONE character plus a period, 

148#: the way "J." stands in for "John"? Han, hangul and kana have no 

149#: such convention, so a lone punctuated 씨/様/김 is not a shortened 

150#: name and the veto has nothing to veto there. Do not restate that 

151#: phonologically ("letters, not syllables") -- Devanagari is an 

152#: abugida and Arabic an abjad, neither has letters in that sense, and 

153#: both abbreviate, so should Script.CYRILLIC or Script.DEVANAGARI 

154#: ever be added neither belongs here; their initials are real and 

155#: pinned as such ("А. С. Пушкин", "م. الفارسي"). 

156#: 

157#: Enumerated rather than derived from _SCRIPT_RANGES' keys: the 

158#: Script enum admits a member so that SOME behavior may key on it 

159#: (see Script), on assorted grounds -- KATAKANA is in it so the 

160#: classifier can name what it deliberately declines, and neither 

161#: DEFAULT_SCRIPT_ORDERS nor segment_scripts' default mentions it. 

162#: Membership therefore settles nothing about abbreviation: the four 

163#: coinciding today is what has been implemented, not a property of 

164#: the enum, and a new member must not inherit this answer. 

165#: 

166#: Decide it from CLDR rather than from the script's typology: count 

167#: the LOCALE-AUTHORED namePattern entries in common/main/<locale>.xml's 

168#: personNames block that produce an initial. ja and ko author 29 and 

169#: 32 patterns and use one in none of them; ru uses initials in 7 of 39 

170#: ("{given-initial} {given2-initial} {surname}"); zh abbreviates but 

171#: overrides initialPattern to "{0}", no period, which is why the 

172#: period is part of the test above. Do NOT read initialPattern alone 

173#: -- root defaults it to "{0}." and nearly every locale inherits it, 

174#: so it reports a period convention for locales that have none. 

175#: Measured 2026-08-02: th is 0 of 20, so Thai (#317) belongs here 

176#: once it earns a member. 

177_NO_INITIALS = (Script.HAN, Script.HANGUL, Script.HIRAGANA, 

178 Script.KATAKANA) 

179 

180 

181def _script_matcher(*scripts: Script, 

182 whole: bool = False) -> Callable[[str], bool]: 

183 """A predicate over strings, compiled once from the union of the 

184 named scripts' spans in _SCRIPT_RANGES. whole=False: True when the 

185 string CONTAINS any such character -- DEVIATES' contract, where 

186 over-declaring is the gate's safe direction. whole=True: True when 

187 the string is non-empty and consists WHOLLY of such characters -- 

188 the ja adapter's repertoire guard and _vocab's script 

189 classifiers. Meant to be called at MODULE 

190 scope: "compiled once" is per matcher, and each call compiles a 

191 fresh pattern. The compiled pattern lives in the closure ON 

192 PURPOSE, and the compilation lives HERE rather than in a pack- 

193 local closure: tests/v2/test_locales.py classifies any pack module 

194 holding a module-level re.Pattern as a marker pack needing rotator 

195 branch coverage, and its registry gate goes further -- a pack that 

196 so much as IMPORTS re without exposing such a pattern fails 

197 "imports re but exposes no module-level pattern" -- so a 

198 range-declaring pack must not import re at all; predicates built 

199 here keep the packs invisible to that sweep by construction (and 

200 spare _vocab's derived matchers a declaration row in 

201 tests/v2/test_regex_sync.py's completeness sweep, which scans the 

202 pipeline modules (plus _render) for private module-level 

203 patterns).""" 

204 if not scripts: 

205 raise ValueError("_script_matcher needs at least one Script") 

206 cls = "".join(f"\\U{lo:08x}-\\U{hi:08x}" 

207 for script in scripts 

208 for lo, hi in _SCRIPT_RANGES[script]) 

209 # one pattern serves both modes: fullmatch of [cls]+ is wholly-of, 

210 # and search over [cls]+ is exactly contains-any 

211 pattern = re.compile(f"[{cls}]+") 

212 match = pattern.fullmatch if whole else pattern.search 

213 

214 def matcher(text: str) -> bool: 

215 return match(text) is not None 

216 return matcher 

217 

218 

219# Order-spec constants (#270). Each reads as its contents because roles 

220# are named given/family, not first/last. 

221 

222#: Western order (the default): the first word of positional input is 

223#: the given name, the last is the family name, everything between is 

224#: middle. One of the three valid ``Policy(name_order=...)`` values. 

225GIVEN_FIRST = (Role.GIVEN, Role.MIDDLE, Role.FAMILY) 

226#: Family name first, given name second, remaining words middle 

227#: (e.g. Hungarian, or East Asian order). One of the three valid 

228#: ``Policy(name_order=...)`` values. 

229FAMILY_FIRST = (Role.FAMILY, Role.GIVEN, Role.MIDDLE) 

230#: Family name first, given name LAST, words between middle 

231#: (e.g. Vietnamese full-name order). One of the three valid 

232#: ``Policy(name_order=...)`` values. 

233FAMILY_FIRST_GIVEN_LAST = (Role.FAMILY, Role.MIDDLE, Role.GIVEN) 

234 

235_ORDER_CONSTANT_NAMES: dict[tuple[Role, ...], str] = { 

236 GIVEN_FIRST: "GIVEN_FIRST", 

237 FAMILY_FIRST: "FAMILY_FIRST", 

238 FAMILY_FIRST_GIVEN_LAST: "FAMILY_FIRST_GIVEN_LAST", 

239} 

240 

241 

242def _order_repr(value: tuple[Role, ...]) -> str: 

243 # Unreachable via Policy's constructor (its __post_init__ restricts 

244 # name_order to the three named orders) but REACHABLE via 

245 # PolicyPatch, which defers name_order validation to apply time by 

246 # design -- value may hold non-Role, even unhashable, elements. A 

247 # value smuggled in through __setstate__ (which validates layout, 

248 # not values) can also be a non-tuple container or not iterable at 

249 # all. repr must never raise, so the named-lookup path is taken 

250 # only for a TUPLE whose every element is confirmed a Role; 

251 # everything else renders via repr(value). (The annotation states 

252 # the Policy-side truth; the PolicyPatch call site passes 

253 # getattr-Any.) 

254 if isinstance(value, tuple) and all(isinstance(r, Role) for r in value): 

255 named = _ORDER_CONSTANT_NAMES.get(value) 

256 if named is not None: 

257 return named 

258 return "(" + ", ".join(r.name for r in value) + ")" 

259 return repr(value) 

260 

261 

262# Single source for the migration hint raised by both Policy and 

263# PolicyPatch when patronymic_rules gets a non-iterable (True is the 

264# likeliest wrong value -- v1's flag was a bool that enabled BOTH rules). 

265_PATRONYMIC_MIGRATION_HINT = ( 

266 "v1's patronymic_name_order=True enabled both rules -- " 

267 "patronymic_rules={PatronymicRule.EAST_SLAVIC, " 

268 "PatronymicRule.TURKIC} (or pick one via " 

269 "parser_for(locales.RU) / locales.TR_AZ)" 

270) 

271 

272#: Policy.script_orders' default: wholly-Han, wholly-Hangul, and 

273#: kana-licensed names read family-first. Public and named so opting 

274#: out or extending reads against a documented value (the 

275#: DEFAULT_NICKNAME_DELIMITERS precedent). The HAN entry is safe 

276#: WITHOUT knowing Chinese from Japanese: both write family-first in 

277#: native script -- the languages differ, the convention doesn't. 

278#: HIRAGANA joins by the same rule as HANGUL (the kana license, 

279#: amendment 2026-07-29): a mixed Han-and-kana token cannot be 

280#: Chinese (it contains kana) and is not a foreign transcription 

281#: (transcriptions are katakana-only), so it is Japanese, written 

282#: family-first -- another default change in a minor, release-log- 

283#: classified fix, #294's mechanism. KATAKANA is deliberately absent: 

284#: a PURE-katakana token is predominantly a transcribed foreign name 

285#: kept in its source (usually given-first) order, so nothing should 

286#: default on it. Canonical form: sorted (Script, order) pairs, 

287#: matching the field's storage. 

288DEFAULT_SCRIPT_ORDERS: tuple[ 

289 tuple[Script, tuple[Role, Role, Role]], ...] = ( 

290 (Script.HAN, FAMILY_FIRST), 

291 (Script.HANGUL, FAMILY_FIRST), 

292 (Script.HIRAGANA, FAMILY_FIRST), 

293) 

294 

295#: Policy.nickname_delimiters' default. Public and named so 

296#: customizations read as set math against a documented value -- e.g. 

297#: ``DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}`` -- instead of a 

298#: rebuilt literal the user had to go discover. The v1 trio (straight 

299#: quotes + parentheses) plus the typographic conventions (#273): 

300#: smart quotes, low-high and right-right quotes, guillemets both 

301#: directions, CJK corner brackets, fullwidth parentheses. Curly 

302#: SINGLE quotes are deliberately absent: U+2019 is the typographic 

303#: apostrophe ("O’Connor"). 

304DEFAULT_NICKNAME_DELIMITERS = frozenset({ 

305 ("'", "'"), ('"', '"'), ("(", ")"), # v1 trio 

306 ("“", "”"), # smart quotes (en, zh) 

307 ("„", "“"), # low-high (de, pl, cs, hu) 

308 ("”", "”"), # right-right (sv, fi) 

309 ("«", "»"), # guillemets (fr, ru, it, el) 

310 ("»", "«"), # reversed guillemets (de alt) 

311 ("「", "」"), ("『", "』"), # CJK corner brackets (ja) 

312 ("(", ")"), # fullwidth parentheses (CJK) 

313}) 

314 

315 

316def _reject_bare_string_order(value: object, field_name: str) -> None: 

317 # tuple("gmf") would be ("g", "m", "f") -- catch the bare string 

318 # with the same TypeError every other iterable field raises. 

319 # Single-sourced: called from Policy AND PolicyPatch __post_init__. 

320 # field_name is REQUIRED, with no name_order default: script_orders' 

321 # values obey the same rule (via _validated_order), and a defaulted 

322 # caller that forgot to pass it would silently name the wrong field. 

323 if isinstance(value, str): 

324 raise TypeError( 

325 f"{field_name} must be an iterable of three Roles, " 

326 f"not a bare string: {value!r}" 

327 ) 

328 # A {Role: position} dict iterates to the right three Roles in the 

329 # right order, so it would be accepted -- harmlessly today, since 

330 # the result is checked against the three exported orders anyway. 

331 # Guarded regardless: "iterating this yields something plausible 

332 # but not what you wrote" is one bug class, and leaving one field 

333 # out of it is how the PolicyPatch hole happened. 

334 if isinstance(value, Mapping): 

335 raise TypeError( 

336 f"{field_name} must be an iterable of three Roles, not a " 

337 f"mapping: {value!r}" 

338 ) 

339 if isinstance(value, (bytes, bytearray, memoryview)): 

340 raise TypeError( 

341 f"{field_name} must be an iterable of three Roles, not " 

342 f"{type(value).__name__} -- decode first, e.g. " 

343 f"raw.decode('utf-8')" 

344 ) 

345 

346 

347def _reject_str_and_mapping(value: object, field_name: str) -> None: 

348 """The two shapes that iterate into something plausible but wrong. 

349 

350 A bare string yields its characters (and '' yields nothing at all, 

351 so it silently stored an empty set); a Mapping yields only its keys. 

352 Both used to be accepted here, storing a value the caller never 

353 wrote. Lexicon._normset rejects the same two by name -- the wording 

354 is deliberately parallel, since a caller who hits one field's guard 

355 should recognize the other's. 

356 """ 

357 if isinstance(value, str): 

358 raise TypeError( 

359 f"{field_name} must be an iterable, not a bare string: " 

360 f"{value!r}" 

361 ) 

362 # bytes iterate to ints, so the entry check would report a byte 

363 # value and name neither the cause nor the fix; same decode hint 

364 # Lexicon and parse() give. 

365 if isinstance(value, (bytes, bytearray, memoryview)): 

366 raise TypeError( 

367 f"{field_name} must be an iterable of strings, not " 

368 f"{type(value).__name__} -- decode first, e.g. " 

369 f"raw.decode('utf-8')" 

370 ) 

371 if isinstance(value, Mapping): 

372 # Name the way out, not just the harm. "contributes only its 

373 # keys" is no help for the two mappings people actually pass: 

374 # {} (meant as an empty set -- Python's oldest trap, and the 

375 # keys story does not apply because there are none), and an 

376 # {open: close} dict, whose keys are only half the pair. 

377 raise TypeError( 

378 f"{field_name} must be an iterable, not a mapping: " 

379 f"{value!r}. A mapping yields only its keys -- write " 

380 f"frozenset() for an empty set, or .items() if this is an " 

381 f"{{open: close}} pair mapping" 

382 ) 

383 

384 

385def _require_iterable(value: Iterable[Any], field_name: str, 

386 expected: str = "an iterable") -> Iterable[Any]: 

387 """Probe a value's iterability and return its iterator, raising a 

388 TypeError naming the field if it has none. `expected` carries the 

389 field's own phrasing ("a mapping of Script to order"); the default 

390 suits every plain iterable field. 

391 

392 Probing with iter() is what makes the message possible: a 

393 non-iterable (an int, a bool, ...) is named here, matching the 

394 treatment patronymic_rules already gets, instead of a bare "'int' 

395 object is not iterable" surfacing from whatever tuple()/frozenset() 

396 happens to run first. It is ALSO the reason callers consume the 

397 returned iterator OUTSIDE any try of their own: an exception raised 

398 inside a caller's generator while it is being consumed is the 

399 caller's own error, and must propagate untouched rather than be 

400 rewritten as a shape complaint about the field. 

401 """ 

402 try: 

403 return iter(value) 

404 except TypeError: 

405 raise TypeError( 

406 f"{field_name} must be {expected}, got {value!r}" 

407 ) from None 

408 

409 

410def _validated_order(value: Iterable[Any], 

411 field_name: str) -> tuple[Role, Role, Role]: 

412 """name_order's element/permutation check, single-sourced so 

413 script_orders values obey the identical rule (only the three 

414 exported orders have implemented assignment semantics).""" 

415 _reject_bare_string_order(value, field_name) 

416 order = tuple(_require_iterable(value, field_name)) 

417 # Sole rejection point for plain-string tuples: Role is a StrEnum, 

418 # so the named-order membership check below compares EQUAL for 

419 # ("given", "middle", "family") -- do not remove this loop as 

420 # redundant. 

421 for element in order: 

422 if not isinstance(element, Role): 

423 raise TypeError( 

424 f"{field_name} elements must be Role members, " 

425 f"got {element!r}" 

426 ) 

427 # Only the three exported orders have implemented assignment 

428 # semantics; the unnamed permutations would silently misassign. 

429 # Pre-2.0 strictness is free -- relaxing later is compatible. 

430 if order not in (GIVEN_FIRST, FAMILY_FIRST, 

431 FAMILY_FIRST_GIVEN_LAST): 

432 raise ValueError( 

433 f"{field_name} must be one of the exported orders, got " 

434 f"{order!r}; use GIVEN_FIRST, FAMILY_FIRST, or " 

435 f"FAMILY_FIRST_GIVEN_LAST" 

436 ) 

437 return order # type: ignore[return-value] # length checked above 

438 

439 

440def _validated_script(key: object) -> Script: 

441 """Coerce one Script key, with the message every script-keyed field 

442 shares. Single-sourced deliberately: script_orders and the 

443 script-keyed fields that follow it must not each grow their own 

444 wording for the same lookup.""" 

445 try: 

446 # Enum lookup accepts ANY value at runtime and answers a 

447 # non-member with ValueError -- which is the contract here, and 

448 # the taxonomy's rule for a failed enum lookup whatever the 

449 # input type was (stdlib EnumType precedent). 

450 return Script(key) # type: ignore[arg-type] 

451 except ValueError: 

452 valid = ", ".join(v.value for v in Script) 

453 raise ValueError( 

454 f"unknown script {key!r}; valid scripts: {valid}" 

455 ) from None 

456 

457 

458def _validated_script_orders( 

459 value: object) -> tuple[tuple[Script, tuple[Role, Role, Role]], ...]: 

460 """Policy.script_orders' whole check, from raw input to canonical 

461 storage. script_orders is the one MAPPING-shaped field, so the 

462 guards read inverted from every other one here: a Mapping is what 

463 the caller SHOULD pass, and a bare string is the shape that would 

464 otherwise iterate into plausible-looking garbage.""" 

465 if isinstance(value, str): 

466 raise TypeError( 

467 f"script_orders must be a mapping of Script to order, " 

468 f"not a bare string: {value!r}" 

469 ) 

470 if isinstance(value, (bytes, bytearray, memoryview)): 

471 raise TypeError( 

472 f"script_orders must be a mapping of Script to order, " 

473 f"not {type(value).__name__} -- decode first, " 

474 f"e.g. raw.decode('utf-8')" 

475 ) 

476 raw = value.items() if isinstance(value, Mapping) else value 

477 raw_iter = _require_iterable( 

478 raw, "script_orders", # type: ignore[arg-type] 

479 "a mapping of Script to order") 

480 canonical: dict[Script, tuple[Role, Role, Role]] = {} 

481 for entry in raw_iter: 

482 try: 

483 key, order = entry 

484 except (TypeError, ValueError): 

485 raise TypeError( 

486 f"script_orders entries must be (Script, order) " 

487 f"pairs, got {entry!r}" 

488 ) from None 

489 canonical[_validated_script(key)] = _validated_order( 

490 order, "script_orders") 

491 # Sorted pairs, not a dict: Policy is hashable, and two 

492 # differently-written but equivalent tables must converge (the 

493 # capitalization_exceptions precedent). 

494 return tuple(sorted(canonical.items())) 

495 

496 

497def _validated_segment_scripts(value: object) -> frozenset[Script]: 

498 """segment_scripts' check: an iterable of Script members (or 

499 their string values), coerced via _validated_script so the 

500 unknown-script wording stays single-sourced.""" 

501 _reject_str_and_mapping(value, "segment_scripts") 

502 script_iter = _require_iterable( 

503 value, "segment_scripts", # type: ignore[arg-type] 

504 "an iterable of Script members") 

505 return frozenset(_validated_script(s) for s in script_iter) 

506 

507 

508def _canonical_script_pair(pair: Iterable[Any]) -> tuple[Any, ...]: 

509 """Hashability-only canonicalization of one PolicyPatch 

510 script_orders entry: tuple-ize the pair AND the order value inside 

511 it. Shallow was not enough -- a {Script: [Role, ...]} patch stored 

512 the list, and hash() then raised far from the construction site, 

513 the same failure name_order's canonicalization exists to prevent. 

514 A malformed entry is still tuple-ized (that is the hashability 

515 floor); its CONTENTS are left exactly as written so Policy quotes 

516 the caller's own value when it raises at apply time.""" 

517 out = tuple(pair) 

518 if len(out) != 2: 

519 return out # not a (Script, order) pair 

520 key, value = out 

521 # str/bytes are iterable, so tuple() would shred exactly the two 

522 # values whose deferred errors ("not a bare string", the decode 

523 # hint) need to quote what the caller wrote. 

524 if isinstance(value, (str, bytes, bytearray, memoryview)): 

525 return out 

526 try: 

527 return (key, tuple(value)) 

528 except TypeError: 

529 return out # non-iterable order value 

530 

531 

532def _canonical_patch_script_orders(value: object) -> object: 

533 """Canonicalize a PolicyPatch.script_orders value for hashability 

534 without validating it: malformed shapes are stored so Policy can 

535 quote them at apply time; a caller-generator's own exception 

536 propagates from the UNGUARDED materialization below (deferring is 

537 impossible once a one-shot iterator is consumed).""" 

538 # Excluded HERE rather than delegated: a string's elements are 

539 # themselves tuple-izable, so no TypeError ever fires to signal 

540 # "leave this alone" -- "han" would shred to (("h",), ("a",), 

541 # ("n",)) and Policy's bare-string message would have nothing left 

542 # to quote. bytes shred the same way, into ints. 

543 if isinstance(value, (str, bytes, bytearray, memoryview)): 

544 return value # deferred whole: Policy's guards quote it 

545 # Any, not object: a patch defers validation, so anything a caller 

546 # wrote can arrive here, and the probe below is precisely the 

547 # runtime question mypy has no way to answer statically. 

548 pairs: Any = value.items() if isinstance(value, Mapping) else value 

549 try: 

550 pairs_iter = iter(pairs) # probe only 

551 except TypeError: 

552 return value # non-iterable: defer to apply 

553 items = tuple(pairs_iter) # UNGUARDED: caller errors propagate 

554 try: 

555 return tuple(map(_canonical_script_pair, items)) 

556 except TypeError: 

557 return items # malformed entry: materialized, so 

558 # Policy can still re-iterate + quote 

559 

560 

561@dataclass(frozen=True, slots=True) 

562class Policy: 

563 """The behavior switches a parser runs with: name order, 

564 patronymic rules, delimiter routing, input scrubbing. Immutable 

565 and hashable; every field has a safe default, so construct with 

566 only what you change -- ``Policy(maiden_delimiters=frozenset({("(", ")")}))`` 

567 -- and pass the result to ``Parser(policy=...)``.""" 

568 

569 #: How positional (no-comma) input maps onto given/middle/family. 

570 #: Valid values are exactly the three exported 

571 #: :ref:`name-order constants <name-order-constants>` -- 

572 #: GIVEN_FIRST (the default), FAMILY_FIRST, and 

573 #: FAMILY_FIRST_GIVEN_LAST; any other tuple of Roles raises 

574 #: ValueError. Ignored when a comma separates family from given: 

575 #: "Thomas, John" puts the family name first no matter which words 

576 #: could otherwise be either ("Thomas" and "John" both work as 

577 #: given or family names). A comma that only sets off suffixes 

578 #: ("John Smith, Jr.") leaves name_order governing the name part. 

579 name_order: tuple[Role, Role, Role] = GIVEN_FIRST 

580 #: Per-script overrides of name_order (#271), consulted when every 

581 #: name piece is written wholly in one script, or in the 

582 #: Han/Hiragana/Katakana repertoire the #272 kana license shares 

583 #: across pieces: {Script: order} (constructor accepts a mapping; 

584 #: stored as sorted pairs). The default reads wholly-Han/Hangul 

585 #: names, and kana-licensed Japanese names, family-first -- see 

586 #: :data:`~nameparser.DEFAULT_SCRIPT_ORDERS`. Opt out with 

587 #: ``script_orders=()``. Latin-script and mixed-script input is 

588 #: never affected. Like name_order, ignored where a comma already 

589 #: decides the family name. 

590 script_orders: tuple[tuple[Script, tuple[Role, Role, Role]], ...] = ( 

591 DEFAULT_SCRIPT_ORDERS) 

592 #: Scripts for which the unspaced-name segmentation stage is 

593 #: active (#271): the first token written wholly in an activated 

594 #: script is split by longest surname match against 

595 #: :attr:`Lexicon.surnames <nameparser.Lexicon.surnames>`, and, 

596 #: where that vocabulary declines, by a 

597 #: :data:`~nameparser.Segmenter` if one was given to the parser 

598 #: (#272). Default: {Script.HANGUL} -- hangul is unambiguously 

599 #: Korean and Korean surnames are a closed default-shipped set. 

600 #: Han is NOT default: a zh surname list corrupts Japanese names 

601 #: (高橋一郎 must not split as 高+橋一郎), so it's opt-in via 

602 #: locales.ZH for Chinese and locales.JA -- which activates 

603 #: Script.HIRAGANA alongside it, the kana license's carrier key -- 

604 #: for Japanese. 

605 #: Opt out with ``segment_scripts=frozenset()``; note a PolicyPatch unions 

606 #: rather than replaces, so a pack can only add scripts, never 

607 #: disable one. 

608 segment_scripts: frozenset[Script] = frozenset({Script.HANGUL}) 

609 #: Opt-in detectors that reorder patronymic-shaped names 

610 #: (EAST_SLAVIC, TURKIC); usually set via a locale pack. A rotation 

611 #: restores the given-first reading a family-first listing hides, so 

612 #: under a declared FAMILY_FIRST or FAMILY_FIRST_GIVEN_LAST name_order 

613 #: it stands down and the declaration decides. 

614 patronymic_rules: frozenset[PatronymicRule] = frozenset() 

615 #: Folds middle into family instead of splitting them (v1's 

616 #: middle_name_as_last) -- for data where unrecognized interior 

617 #: words are surname parts, not middle names: multi-part surnames 

618 #: like Spanish/Portuguese dual surnames ("Gabriel García Márquez" 

619 #: -> family "García Márquez" instead of middle "García"). 

620 middle_as_family: bool = False # v1's middle_name_as_last 

621 #: (open, close) pairs whose enclosed content becomes the nickname 

622 #: field. Defaults to 

623 #: :data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` (#273). 

624 nickname_delimiters: frozenset[tuple[str, str]] = DEFAULT_NICKNAME_DELIMITERS 

625 #: (open, close) pairs whose enclosed content becomes the maiden 

626 #: field instead; a pair listed here is dropped from the effective 

627 #: nickname set (maiden wins, see __post_init__), so 

628 #: maiden_delimiters=frozenset({("(", ")")}) is the whole recipe (#274). 

629 #: Set this for a clause that says nothing about itself, which is 

630 #: two kinds and not one: content with no marker word in it, and a 

631 #: LONE marker word. Since #335 a clause that opens with a marker 

632 #: word AND has a word after it reads as the maiden name whatever 

633 #: pair encloses it, so "Jane Smith (née Jones)" needs no 

634 #: configuration -- unless the content is suffix-shaped, which is 

635 #: taken first, the brackets dropped and the content read as if 

636 #: written bare. A maiden_markers entry opening the enclosed 

637 #: content is dropped from the value -- all of it, an entry being 

638 #: allowed to span more than one word ("z domu") -- but only where 

639 #: a word stands past it: a clause of nothing but its marker keeps 

640 #: its words, since a lone "(Nee)" is a maiden NAME and not a 

641 #: marker (#329). 

642 maiden_delimiters: frozenset[tuple[str, str]] = frozenset() 

643 #: Additional separators that split suffix groups (e.g. " - " for 

644 #: "Jane Smith, RN - CRNA"). Additive only: the comma always 

645 #: splits suffix groups and cannot be replaced -- comma handling 

646 #: is structural (the same comma reading that parses 

647 #: "Family, Given" input), not a configurable delimiter. 

648 extra_suffix_delimiters: frozenset[str] = frozenset() 

649 #: Governs "Family, Suffix"-shaped input where the suffix word is 

650 #: also initial-shaped (a single letter, bare or period-written -- 

651 #: of the default vocabulary that means the roman numerals "I" and 

652 #: "V"): "John Smith, V" reads as John Smith the fifth when True 

653 #: (the default, v1 behavior); False reads "V" as a given-name 

654 #: initial instead (family "John Smith", given "V"). Multi-letter 

655 #: suffixes ("III", "MD") parse the same either way. 

656 lenient_comma_suffixes: bool = True 

657 #: Excludes emoji from tokenization: they appear in no token, 

658 #: field, or rendered view. The original string keeps them (input 

659 #: is never modified -- spans stay true). 

660 strip_emoji: bool = True 

661 #: Excludes bidirectional control characters from tokenization: 

662 #: they appear in no token, field, or rendered view; the original 

663 #: string keeps them. 

664 strip_bidi: bool = True # =False replaces v1's opt-out CONSTANTS.regexes.bidi = False 

665 

666 # in the class body so @dataclass(slots=True) keeps them 

667 __getstate__ = _guarded_getstate 

668 __setstate__ = _guarded_setstate 

669 

670 # rules.md#D2: "configuration validation raises at construction 

671 # with the offending field and value named" 

672 def __post_init__(self) -> None: 

673 object.__setattr__( 

674 self, "name_order", _validated_order(self.name_order, 

675 "name_order")) 

676 object.__setattr__( 

677 self, "script_orders", 

678 _validated_script_orders(self.script_orders)) 

679 object.__setattr__( 

680 self, "segment_scripts", 

681 _validated_segment_scripts(self.segment_scripts)) 

682 _reject_str_and_mapping(self.patronymic_rules, "patronymic_rules") 

683 # Probe with iter() rather than wrapping tuple(): non-iterables 

684 # (True especially -- v1's patronymic_name_order was a bool flag, 

685 # so it's the likeliest wrong value here) get the migration- 

686 # pointing message, while an exception raised inside a caller's 

687 # generator still propagates untouched from the tuple() below 

688 # instead of being rewritten. Only the enum lookup itself gets 

689 # the unknown-rule message, naming the offender. 

690 try: 

691 rule_iter = iter(self.patronymic_rules) 

692 except TypeError: 

693 raise TypeError( 

694 f"patronymic_rules must be an iterable of PatronymicRule " 

695 f"names, got {self.patronymic_rules!r}; " 

696 f"{_PATRONYMIC_MIGRATION_HINT}" 

697 ) from None 

698 items = tuple(rule_iter) 

699 rules = set() 

700 for r in items: 

701 try: 

702 rules.add(PatronymicRule(r)) 

703 except ValueError: 

704 valid = ", ".join(v.value for v in PatronymicRule) 

705 raise ValueError( 

706 f"unknown patronymic rule {r!r}; valid rules: {valid}" 

707 ) from None 

708 object.__setattr__(self, "patronymic_rules", frozenset(rules)) 

709 for pairs_name in ("nickname_delimiters", "maiden_delimiters"): 

710 _reject_str_and_mapping(getattr(self, pairs_name), pairs_name) 

711 pairs = tuple(_require_iterable(getattr(self, pairs_name), pairs_name)) 

712 for pair in pairs: 

713 if (not isinstance(pair, tuple) or len(pair) != 2 

714 or not all(isinstance(s, str) for s in pair)): 

715 raise TypeError( 

716 f"{pairs_name} entries must be (open, close) tuples " 

717 f"of strings, got {pair!r}" 

718 ) 

719 if not all(pair): 

720 raise ValueError( 

721 f"{pairs_name} entries must be pairs of non-empty " 

722 f"strings, got {pair!r}" 

723 ) 

724 object.__setattr__(self, pairs_name, frozenset(pairs)) 

725 # Maiden wins: a pair can route to exactly one field, and listing 

726 # it in maiden_delimiters is the specific intent, so the effective 

727 # nickname set drops it. Canonicalization, not validation (the 

728 # name_order coercion precedent): differently-written but 

729 # equivalent Policies converge to equal values. The v1 facade 

730 # keeps v1's nickname-wins precedence via a pre-subtraction in 

731 # _config_shim's snapshot instead. 

732 object.__setattr__( 

733 self, "nickname_delimiters", 

734 self.nickname_delimiters - self.maiden_delimiters) 

735 _reject_str_and_mapping(self.extra_suffix_delimiters, 

736 "extra_suffix_delimiters") 

737 delimiters = tuple(_require_iterable( 

738 self.extra_suffix_delimiters, "extra_suffix_delimiters")) 

739 for d in delimiters: 

740 if not isinstance(d, str): 

741 raise TypeError( 

742 f"extra_suffix_delimiters entries must be strings, " 

743 f"got {d!r}" 

744 ) 

745 if not d: 

746 raise ValueError( 

747 "extra_suffix_delimiters entries must be non-empty strings" 

748 ) 

749 object.__setattr__( 

750 self, "extra_suffix_delimiters", frozenset(delimiters) 

751 ) 

752 # Truthy strings ("no", "false") would silently invert the 

753 # caller's intent downstream; bools are the one field kind the 

754 # coercing checks above can't cover. 

755 for flag in ("middle_as_family", "lenient_comma_suffixes", 

756 "strip_emoji", "strip_bidi"): 

757 value = getattr(self, flag) 

758 if not isinstance(value, bool): 

759 raise TypeError( 

760 f"{flag} must be a bool, got {value!r}" 

761 ) 

762 

763 def __repr__(self) -> str: 

764 # Bounded: only fields that deviate from the default are shown 

765 # (design rule, see nameparser._types module docstring). 

766 parts = [] 

767 for f in dataclasses.fields(self): 

768 value = getattr(self, f.name) 

769 if value == f.default: 

770 continue 

771 if f.name == "name_order": 

772 parts.append(f"name_order={_order_repr(value)}") 

773 else: 

774 parts.append(f"{f.name}={value!r}") 

775 return f"Policy({', '.join(parts)})" 

776 

777 # -- editing ------------------------------------------------------ 

778 

779 def patched(self, patch: PolicyPatch) -> Policy: 

780 """Fold a :class:`PolicyPatch` onto this Policy and return the 

781 combined Policy. Set-valued fields union with the patch's; 

782 scalar fields are overridden by the patch; UNSET fields are 

783 left alone. Patch VALUES are validated here (Policy's 

784 constructor re-runs on the result), not at patch construction 

785 -- see PolicyPatch. The maiden-wins canonicalization applies 

786 to the combined result exactly as if it had been constructed 

787 directly.""" 

788 if not isinstance(patch, PolicyPatch): 

789 raise TypeError(f"patched() takes a PolicyPatch, got {patch!r}") 

790 return apply_patch(self, patch) 

791 

792 

793class _Unset(Enum): 

794 UNSET = auto() 

795 

796 

797#: Sentinel for "this patch does not set this field" (picklable enum 

798#: member, distinguishable from every real value including None/False). 

799UNSET = _Unset.UNSET 

800 

801_UNION = {"compose": "union"} # field metadata: set-valued -> union 

802 

803 

804@dataclass(frozen=True, slots=True) 

805class PolicyPatch: 

806 """A partial Policy: one field per Policy field, all defaulting to 

807 UNSET. Composition per field is DECLARED via metadata -- set-valued 

808 fields union, scalars override (later wins). Kept in lockstep with 

809 Policy by the parity test in tests/v2/test_policy.py. 

810 

811 Values are validated when the patch is applied (Policy's constructor 

812 re-runs), not at patch construction. 

813 """ 

814 

815 name_order: tuple[Role, Role, Role] | _Unset = UNSET 

816 #: Composes as a SCALAR (override, not merge) -- deliberate: nothing 

817 #: shipped patches it today, so the simpler rule is the one to 

818 #: defend; revisit if a pack ever needs to add one script's entry 

819 #: without restating the rest. 

820 script_orders: tuple[ 

821 tuple[Script, tuple[Role, Role, Role]], ...] | _Unset = UNSET 

822 segment_scripts: frozenset[Script] | _Unset = field( 

823 default=UNSET, metadata=_UNION) 

824 patronymic_rules: frozenset[PatronymicRule] | _Unset = field( 

825 default=UNSET, metadata=_UNION) 

826 middle_as_family: bool | _Unset = UNSET 

827 nickname_delimiters: frozenset[tuple[str, str]] | _Unset = field( 

828 default=UNSET, metadata=_UNION) 

829 maiden_delimiters: frozenset[tuple[str, str]] | _Unset = field( 

830 default=UNSET, metadata=_UNION) 

831 extra_suffix_delimiters: frozenset[str] | _Unset = field( 

832 default=UNSET, metadata=_UNION) 

833 lenient_comma_suffixes: bool | _Unset = UNSET 

834 strip_emoji: bool | _Unset = UNSET 

835 strip_bidi: bool | _Unset = UNSET 

836 

837 # in the class body so @dataclass(slots=True) keeps them 

838 __getstate__ = _guarded_getstate 

839 __setstate__ = _guarded_setstate 

840 

841 def __post_init__(self) -> None: 

842 # Canonicalize (but do NOT validate) collection fields so a patch 

843 # built from a set/list literal is hashable and unions cleanly in 

844 # apply_patch. name_order needs the same treatment: Policy would 

845 # coerce a list at apply time, but the patch itself (and any 

846 # Locale holding it) must already be hashable. 

847 if self.name_order is not UNSET: 

848 _reject_bare_string_order(self.name_order, "name_order") 

849 object.__setattr__(self, "name_order", tuple(self.name_order)) 

850 # Same reason for script_orders, one level deeper: a patch built 

851 # from a {Script: order} dict (or a list of pairs) must already 

852 # be hashable, since a Locale holds it -- and hashable all the 

853 # way down, hence _canonical_script_pair. Validation still 

854 # belongs to Policy at apply time, so a shape the canonicalizer 

855 # cannot digest is left for it to report; the one-shot and 

856 # malformed-entry cases are _canonical_patch_script_orders'. 

857 if self.script_orders is not UNSET: 

858 object.__setattr__( 

859 self, "script_orders", 

860 _canonical_patch_script_orders(self.script_orders)) 

861 for f in dataclasses.fields(self): 

862 if f.metadata.get("compose") != "union": 

863 continue 

864 value = getattr(self, f.name) 

865 if value is UNSET: 

866 continue 

867 # Shared with Policy, not re-implemented: this used to be an 

868 # inline copy of the bare-string half only, so the mapping 

869 # half never reached a patch -- and frozenset() below 

870 # destroys the evidence, leaving nothing for Policy to catch 

871 # at apply time. A Locale pack ships one of these. 

872 _reject_str_and_mapping(value, f.name) 

873 # same iter() probe as Policy: curated message for 

874 # non-iterables (with the v1-flag hint where it applies), 

875 # caller-generator exceptions propagate from frozenset() 

876 try: 

877 iter(value) 

878 except TypeError: 

879 hint = ("; " + _PATRONYMIC_MIGRATION_HINT 

880 if f.name == "patronymic_rules" else "") 

881 raise TypeError( 

882 f"{f.name} must be an iterable, got {value!r}{hint}" 

883 ) from None 

884 object.__setattr__(self, f.name, frozenset(value)) 

885 # middle_as_family, lenient_comma_suffixes, strip_emoji, and 

886 # strip_bidi are scalar (compose="override") fields and 

887 # DELIBERATELY get no type check here, unlike name_order and 

888 # the union fields above: a PolicyPatch(strip_emoji="off") is 

889 # constructible, and only raises once apply_patch runs 

890 # Policy.__post_init__'s bool check. This is the one place the 

891 # module's eager-validation ethos doesn't apply -- see the 

892 # class docstring ("Values are validated when the patch is 

893 # applied... not at patch construction"). 

894 

895 def __repr__(self) -> str: 

896 # Bounded: only fields the patch actually sets are shown; UNSET 

897 # fields are omitted (design rule, see nameparser._types module 

898 # docstring -- the sibling of Policy's deviation-only repr). 

899 parts = [] 

900 for f in dataclasses.fields(self): 

901 value = getattr(self, f.name) 

902 if value is UNSET: 

903 continue 

904 if f.name == "name_order": 

905 parts.append(f"name_order={_order_repr(value)}") 

906 else: 

907 parts.append(f"{f.name}={value!r}") 

908 return f"PolicyPatch({', '.join(parts)})" 

909 

910 

911def apply_patch(policy: Policy, patch: PolicyPatch) -> Policy: 

912 """Fold a PolicyPatch onto a Policy. Policy.__post_init__ re-runs via 

913 dataclasses.replace, so patched values are revalidated for free -- 

914 including the maiden-wins canonicalization: a patch that adds a 

915 maiden pair removes that pair from the base's effective nickname 

916 set, exactly as if the combined Policy had been constructed 

917 directly. Intended (decided 2026-07-19): maiden_delimiters 

918 membership IS the routing decision, whoever contributes it.""" 

919 updates: dict[str, object] = {} 

920 for f in dataclasses.fields(PolicyPatch): 

921 value = getattr(patch, f.name) 

922 if value is UNSET: 

923 continue 

924 if f.metadata.get("compose") == "union": 

925 value = getattr(policy, f.name) | value 

926 updates[f.name] = value 

927 if not updates: 

928 return policy 

929 # Known mypy limitation with **dict-unpacked replace; see the full 

930 # explanation at Lexicon._edit in _lexicon.py. 

931 return dataclasses.replace(policy, **updates) # type: ignore[arg-type]