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

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

204 statements  

1"""Parser and the module-level parse() for the 2.0 API. 

2 

3Layering: sits on _types/_lexicon/_policy/_locale/_pipeline; never 

4imports _render (enforced by tests/v2/test_layering.py). 

5 

6_default_parser is THE one sanctioned module-level global: a 

7functools.cache'd frozen Parser over default config. 

8""" 

9from __future__ import annotations 

10 

11import dataclasses 

12import functools 

13import warnings 

14from collections.abc import Iterable 

15from dataclasses import dataclass, field 

16from typing import TYPE_CHECKING 

17 

18from nameparser._lexicon import _rechosen_masks 

19from nameparser._lexicon import Lexicon 

20from nameparser._locale import Locale 

21from nameparser._pipeline import run 

22from nameparser._pipeline._assemble import assemble 

23from nameparser._pipeline._post_rules import suffix_entries 

24from nameparser._pipeline._state import ParseState 

25from nameparser._pipeline._vocab import _SCRIPT_MATCHERS 

26from nameparser._policy import UNSET, Policy, PolicyPatch, _Unset, apply_patch 

27from nameparser._types import ( 

28 FOLDED_TAG, ParsedName, Role, Segmenter, Token, _guarded_getstate, 

29 _guarded_setstate, _validated_field_strings, 

30) 

31 

32if TYPE_CHECKING: 

33 from nameparser._match import Matching, Outcome 

34 from nameparser._suggest import Suggestion 

35 

36 

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

38class Parser: 

39 """A configured name parser: a :class:`Lexicon` (vocabulary) plus 

40 a :class:`Policy` (behavior), both defaulted when omitted. Build 

41 one when you need non-default configuration, build it once, and 

42 call :meth:`parse` many times -- it is immutable and thread-safe. 

43 

44 An optional keyword-only ``segmenter`` (a :data:`~nameparser.Segmenter`) 

45 plugs in outside knowledge of where an unspaced CJK token divides -- 

46 Japanese kanji names, which no bundled list can settle. It is 

47 consulted only for a token the segmentation stage gates in and the 

48 vocabulary DECLINES, so a locale pack's surnames always win where 

49 they match; returning None declines in turn and the token stays 

50 whole. Two promises narrow when one is supplied (the first is 

51 rules/A1's Accepted bullet "The one exception to totality"): 

52 parse-totality gains its one exception -- an exception raised by 

53 the segmenter propagates, because a user-supplied callable's own 

54 error is a user-code error, not a content error -- and this Parser 

55 pickles only if its segmenter does (a module-level function 

56 pickles; a lambda or closure does not). With no segmenter, both 

57 promises hold unconditionally: all validity checking happens at 

58 construction, so a Parser that constructs successfully cannot fail 

59 at parse time on any str content. 

60 

61 (The None field defaults resolve in __post_init__; after 

62 construction lexicon and policy are always non-None -- the 

63 annotations state the steady-state truth, hence the assignment 

64 ignores on the defaults.)""" 

65 

66 lexicon: Lexicon = None # type: ignore[assignment] # None -> default() 

67 policy: Policy = None # type: ignore[assignment] # None -> Policy() 

68 #: An optional hook supplying outside knowledge of where an unspaced 

69 #: token divides -- see the class docstring; None leaves such tokens 

70 #: whole. Keyword-only, so the reserved growth stays additive: 

71 #: positional construction keeps its two-argument shape. 

72 segmenter: Segmenter | None = field(default=None, kw_only=True) 

73 

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

75 __getstate__ = _guarded_getstate 

76 __setstate__ = _guarded_setstate 

77 

78 def __post_init__(self) -> None: 

79 if self.lexicon is None: 

80 object.__setattr__(self, "lexicon", Lexicon.default()) 

81 elif not isinstance(self.lexicon, Lexicon): 

82 raise TypeError( 

83 f"lexicon must be a Lexicon or None, got {self.lexicon!r}") 

84 if self.policy is None: 

85 object.__setattr__(self, "policy", Policy()) 

86 elif not isinstance(self.policy, Policy): 

87 raise TypeError( 

88 f"policy must be a Policy or None, got {self.policy!r}") 

89 if self.segmenter is not None and not callable(self.segmenter): 

90 raise TypeError( 

91 f"segmenter must be callable or None, got {self.segmenter!r}") 

92 # A configuration gap that used to be silent (#272's API, made 

93 # loud before 2.1.0): segment_scripts can activate a script 

94 # that neither the vocabulary nor a segmenter can ever divide 

95 # -- the JA pack's whole shape, when its segmenter is 

96 # forgotten. The parser then behaves identically to a working 

97 # one minus the feature, which reads as "not working" with no 

98 # signal why. Statically decidable here, so say it here; a 

99 # warning rather than an error because the segmentation-inert pack is a 

100 # pinned, deliberate property (a JA registration must be safe 

101 # without the extra), and warnings are filterable by the rare 

102 # caller who wants exactly that. 

103 if self.segmenter is None: 

104 uncovered = sorted( 

105 script.value 

106 for script in self.policy.segment_scripts 

107 if not any(_SCRIPT_MATCHERS[script](entry) 

108 for entry in self.lexicon._sets.surnames)) 

109 if uncovered: 

110 names = ", ".join(uncovered) 

111 one = len(uncovered) == 1 

112 # the ja hint only where a Japanese script is among the 

113 # dead ones -- a hangul-only gap (a from-scratch 

114 # lexicon under the default policy) has different 

115 # remedies, and pointing it at ja_segmenter would be a 

116 # non sequitur 

117 ja_hint = ( 

118 " For Japanese, pass " 

119 "segmenter=locales.ja_segmenter() (install with: " 

120 "pip install 'nameparser[ja]')." 

121 if {"han", "hiragana", "katakana"} & set(uncovered) 

122 else "") 

123 warnings.warn( 

124 f"Policy.segment_scripts activates {names} but the " 

125 f"vocabulary has no surnames in " 

126 f"{'that script' if one else 'those scripts'} " 

127 f"and no segmenter is configured: unspaced names " 

128 f"written in {'it' if one else 'them'} will never " 

129 f"divide. Supply covering surnames, pass a " 

130 f"segmenter, or deactivate with " 

131 f"Policy(segment_scripts=frozenset()).{ja_hint}", 

132 UserWarning, stacklevel=3) 

133 

134 def __repr__(self) -> str: 

135 # composes the two bounded component reprs; the 

136 # segmenter shows by name, and only when one is set, so the 

137 # default Parser's repr is unchanged 

138 seg = "" 

139 if self.segmenter is not None: 

140 # never repr() the callable itself: a partial reprs its 

141 # bound arguments and a callable instance its address, both 

142 # unbounded -- the class name is the bounded fallback 

143 name = (getattr(self.segmenter, "__qualname__", None) 

144 or type(self.segmenter).__name__) 

145 seg = f", segmenter={name}" 

146 return f"Parser({self.lexicon!r}, {self.policy!r}{seg})" 

147 

148 # -- properties ------------------------------------------------------ 

149 

150 @property 

151 def languages(self) -> frozenset[str]: 

152 """The languages this parser's vocabulary is chosen under, 

153 :attr:`Lexicon.languages` read through: set by 

154 :func:`parser_for`, empty where none was chosen. Added in 

155 3.0.""" 

156 return self.lexicon.languages 

157 

158 @property 

159 def strict(self) -> bool: 

160 """Whether the choice is strict, :attr:`Lexicon.strict` read 

161 through. Added in 3.0.""" 

162 return self.lexicon.strict 

163 

164 def parse(self, text: str) -> ParsedName: 

165 """Parse one name string into a :class:`ParsedName`. Never 

166 raises on string content (unparseable input yields empty 

167 fields plus ambiguities); non-str raises TypeError eagerly, 

168 with a decode hint for bytes (bytes support ended with 1.x). 

169 The one exception to that totality is a configured 

170 ``segmenter``, whose own exceptions propagate (see the class 

171 docstring).""" 

172 if isinstance(text, bytes): 

173 raise TypeError( 

174 "parse() takes str, not bytes -- decode first, e.g. " 

175 "raw.decode('utf-8')") 

176 if not isinstance(text, str): 

177 raise TypeError(f"parse() takes str, got {text!r}") 

178 state = ParseState(original=text, lexicon=self.lexicon, 

179 policy=self.policy, segmenter=self.segmenter) 

180 return assemble(run(state)) 

181 

182 # -- editing ---------------------------------------------------------- 

183 

184 def revise(self, name: ParsedName, **fields: str) -> ParsedName: 

185 """:meth:`ParsedName.replace` with this parser's vocabulary: 

186 each value is tokenized and classified by a full sub-parse, so 

187 the stable tags survive and the tag-driven views 

188 (family_particles, initials(), the suffix join, and since #407 

189 capitalized()) behave as if the text had been parsed. The 

190 value is classified ON ITS OWN, though -- a word whose reading 

191 depends on surrounding context may classify differently than 

192 it would in place, and a glued CJK honorific the whole name 

193 kept on an initial may peel in the value. The sub-parse's role 

194 choices and ambiguities are discarded -- every harvested token 

195 takes the named field's role -- and its structural behavior 

196 applies: delimiter characters do not become tokens, and a 

197 maiden marker is consumed as in parsing -- mid-value always, 

198 and leading a DELIMITED value under a policy routing that pair 

199 to maiden, where "(née Jones)" revises to "Jones" while the 

200 bare "née Jones" keeps its marker, a leading marker in an 

201 undelimited value being no marker at all (#329). 

202 A suffix value's ENTRY structure is derived from the value's 

203 own commas after the role is forced, by the rule a whole name 

204 uses: a comma parts two credentials and a space joins them, so 

205 ``revise(n, suffix="MD PhD")`` is one entry and a name's 

206 rendered suffix revises back to itself wherever the value's 

207 words read as the whole name read them -- the honorific peel 

208 above was the one corpus exception of 368 suffix-bearing names, 

209 2026-09-06 (#511). A second came with #601/#602: a suffix 

210 holding a maiden marker the whole name left a word, whose 

211 value read on its own takes the clause and drops the marker 

212 ('Jane Doe PhD nee Smith' renders 'PhD nee Smith', which 

213 revises to 'PhD Smith'), three corpus names on 2026-10-04. A delimiter the policy names through 

214 ``extra_suffix_delimiters`` parts a value only where the 

215 value's own words, read as a name, give it a tail segment for 

216 the core to be dropped on; a run of post-nominals has none, 

217 with or without a comma of its own (``"MD PhD - FACS"`` and 

218 ``"MD, PhD - FACS"`` both keep the dash as a word), so write a 

219 comma at the boundary you want rather than the delimiter. 

220 Tokens are synthetic (span=None); original is unchanged; a 

221 value with no name content (empty, whitespace, or punctuation 

222 only) clears the field; ambiguities referencing replaced 

223 tokens are dropped.""" 

224 if not isinstance(name, ParsedName): 

225 raise TypeError(f"revise() takes a ParsedName, got {name!r}") 

226 replaced = _validated_field_strings(fields) 

227 harvested: dict[Role, tuple[Token, ...]] = {} 

228 for role, value in replaced.items(): 

229 # the same construction parse() makes, spelled twice 

230 # rather than through a helper: routing parse() through 

231 # one cost a frame per parse on the hot path (py3.11, 

232 # 2026-09-06: 415 calls/name against 414 without it, in a 

233 # 402-418 band), the same trade _mark_suffix_entries 

234 # refused, one row over 

235 state = run(ParseState(original=value, lexicon=self.lexicon, 

236 policy=self.policy, 

237 segmenter=self.segmenter)) 

238 dropped = set(state.dropped) 

239 # Force the role BEFORE the entry pass: it keys on 

240 # Role.SUFFIX, and a bare value's sub-parse reads its words 

241 # as a name ('RA PhD' is a title and a family there), so 

242 # inside the sub-parse it joined nothing. The sub-parse's 

243 # tags are KEPT, "joined" included: a within-piece mark 

244 # (the Ph. D. merge) is role-blind and right for every 

245 # role, and a clear was measured and backed out -- 

246 # decisions/R1 (2026-09-06 #511) carries the rest: the 

247 # between-piece mark that rides onto a non-suffix role, and 

248 # which views read it. Dropped tokens keep their role: the 

249 # pass filters them by index and assemble omits them. 

250 forced = tuple( 

251 tok if i in dropped 

252 else dataclasses.replace(tok, role=role) 

253 for i, tok in enumerate(state.tokens)) 

254 # rules/R1: "a run of post-nominals written with spaces 

255 # renders with spaces, and one written with commas keeps 

256 # them" -- the same pass post_rules runs last, over the 

257 # value's own comma offsets, so the harvest carries exactly 

258 # the entry structure the value's commas describe. Run for 

259 # every role: for a non-suffix role it is a no-op, kept 

260 # unconditional as the simpler contract, revise() being off 

261 # the call-count band. 

262 entried = suffix_entries( 

263 dataclasses.replace(state, tokens=forced)) 

264 harvested[role] = tuple( 

265 Token(t.text, None, role, t.tags - {FOLDED_TAG}) 

266 for t in assemble(entried).tokens) 

267 return name._with_field_tokens(harvested) 

268 

269 # -- comparison ------------------------------------------------------- 

270 

271 def matches(self, a: str | ParsedName, b: str | ParsedName) -> bool: 

272 """Component-wise case-insensitive comparison of two names, 

273 parsing str arguments with THIS parser. 

274 :meth:`ParsedName.matches` parses its str argument with the 

275 DEFAULT parser instead -- for names parsed with a custom 

276 Parser, use this method.""" 

277 if isinstance(a, str): 

278 a = self.parse(a) 

279 elif not isinstance(a, ParsedName): 

280 raise TypeError(f"matches() takes str or ParsedName, got {a!r}") 

281 if isinstance(b, str): 

282 b = self.parse(b) 

283 elif not isinstance(b, ParsedName): 

284 raise TypeError(f"matches() takes str or ParsedName, got {b!r}") 

285 return a.comparison_key() == b.comparison_key() 

286 

287 def matching( 

288 self, accept: Iterable[Outcome | str] | None = None, *, 

289 other_readings: bool = False, 

290 ) -> Matching: 

291 """A :class:`~nameparser.Matching` bound to THIS parser, 

292 so its ``check`` and ``keys`` parse str arguments with this 

293 parser's lexicon and policy. A ParsedName argument is compared 

294 as given, whatever parser produced it. ``accept`` is the 

295 iterable of outcomes counted as consistent; None takes the 

296 default set, and any other value replaces it, so extend the 

297 default with ``parser.matching(accept=parser.matching().accept 

298 | {"family-initial"})``. ``other_readings=True`` also searches 

299 each side's other readings (``Matching.other_readings``). A 

300 value is validated by ``Matching``, which raises for a bad one.""" 

301 from nameparser._match import DEFAULT_ACCEPT, Matching 

302 return Matching( 

303 self, DEFAULT_ACCEPT if accept is None else accept, # type: ignore[arg-type] 

304 other_readings) 

305 

306 def suggest(self, names: Iterable[str]) -> Suggestion: 

307 """The settings a group of names suggests, with THIS parser as 

308 the base: a :class:`~nameparser.Suggestion` whose ``parser`` 

309 reads the group. ``names`` is any iterable of str the caller 

310 declares to be one group (one source, one convention), read 

311 once. Each name casts votes on its name order and its 

312 patronymic shape, read with this parser's lexicon past the 

313 titles it lists at the head and the post-nominals at the end; a 

314 name of fewer than two words once those are read past casts 

315 none, and so does a name with a comma, unless only 

316 post-nominals follow the comma. A patronymic votes only where 

317 the patronymic rule reads one, and a trailing initial 

318 (``Smith J.``) only where at least three names, and 60% of the 

319 group's names holding a name word, end in initials no other 

320 signal in the same name contradicts. ``name_order`` is 

321 decided in either direction, keeping this parser's family-first 

322 spelling where it has one, where at least two names vote for 

323 one end and they are more than two thirds of the names casting 

324 an order vote. A patronymic rule is decided where more than four 

325 in five of the names that cast a vote carry its patronymic (and 

326 at least five do), for the East Slavic and the Turkic rule alike; 

327 a name whose only vote was a set-aside trailing initial counts 

328 among the names that cast one, and the patronymic evidence's 

329 note says how many. 

330 A group decided East Slavic gets the rule and reads given-first, 

331 since the rule reorders each name and stands down under a 

332 family-first order, unless its names' own votes decide 

333 family-first, which the rule never overrides; a Turkic group 

334 gets the rule alone, its order decided by its names' own votes. 

335 ``initials_as_family`` (rules/O8) is turned on where one run of 

336 letters ends at least two names and at least half of the names 

337 ending in letters (Nepal's ``B K``), and a group decided so 

338 casts no trailing-initial vote on its order; a group never turns 

339 it off, nor turns it on under a base reading names family first 

340 or folding every middle into the family, where it would change 

341 no name. A setting the votes do not decide keeps this parser's value, so 

342 an empty group, or one with no votes, suggests no change. A 

343 bare str, bytes, a mapping or a non-str item raises TypeError. 

344 

345 Measured 2026-11-10, re-taken 2026-11-11, with 

346 tools/differential/suggest_measure.py 

347 on groups drawn from real names written in a known order, 300 

348 groups a size (decisions/group-suggestion's 2026-11-11 TRAILING 

349 INITIAL entry holds the table): a group written in this parser's own order kept 

350 it in at least 98% of groups at every size from 2 to 200 names, 

351 under either base order, save one population: ORCID's credit 

352 names written as a byline with the initials first (``GH Yoon``), 

353 whose joined initials the capitals signal reads as a family name, 

354 kept their given-first order in 96% of groups of two and 0% of 

355 groups of fifty, before this gate as after it. Moving the base takes more names, and 

356 how many depends on what the names carry: groups of Chinese, 

357 Taiwanese, Korean, Czech or Polish names (the romanized surnames 

358 and the common given names) were read right in 95% of groups 

359 from 5 names; German, Dutch, Italian, Spanish, British and 

360 Vietnamese from 10; French, US, Japanese and Turkish from 20; 

361 Nigerian, Romanian, Russian and Brazilian from 50; and Indian 

362 only at 200. A group whose votes fall short of the thresholds keeps 

363 the base rather than guess, so a smaller group moves it in fewer 

364 of the draws. 

365 """ 

366 from nameparser._suggest import suggest 

367 return suggest(self, names) 

368 

369 # -- rendering delegates ---------------------------------------------- 

370 

371 def capitalized(self, name: ParsedName, *, 

372 force: bool = False) -> ParsedName: 

373 """:meth:`ParsedName.capitalized` under THIS parser's lexicon. 

374 The no-argument form of that method uses the DEFAULT lexicon -- 

375 for names parsed with a custom Parser, use this method.""" 

376 if not isinstance(name, ParsedName): 

377 raise TypeError(f"capitalized() takes a ParsedName, got {name!r}") 

378 return name.capitalized(self.lexicon, force=force) 

379 

380 

381@functools.cache 

382def _default_parser() -> Parser: 

383 return Parser() 

384 

385 

386def parse(text: str) -> ParsedName: 

387 """Parse a name with the default configuration and return a 

388 :class:`ParsedName`. Equivalent to ``Parser().parse(text)``; build 

389 your own :class:`Parser` (or use :func:`parser_for`) for custom 

390 vocabulary or behavior. Never raises on string content.""" 

391 return _default_parser().parse(text) 

392 

393 

394def _reading_index_selected(languages: frozenset[str], 

395 strict: bool = False) -> frozenset[str]: 

396 # the reading index loads on first lookup, so a caller who chooses 

397 # no language never imports it (decisions/vocabulary-model, 3a) 

398 from nameparser._reading_index import selected 

399 return selected(languages, strict=strict) 

400 

401 

402def _rewrapped(exc: Exception, message: str) -> Exception: 

403 """`exc`'s message with `message` before it, as a plain TypeError or 

404 ValueError: a subclass may take other constructor arguments 

405 (_lexicon._MaskValueError takes three).""" 

406 return (TypeError if isinstance(exc, TypeError) else ValueError)( 

407 f"{message}: {exc}") 

408 

409 

410def _with_packs(lexicon: Lexicon, locales: tuple[Locale, ...], 

411 languages: frozenset[str], strict: bool) -> Lexicon: 

412 """`lexicon` with the packs' fragments joined before it, in pack 

413 order, so its own readings outrank every pack's and a later pack's 

414 an earlier one's, and the choice of `languages` recorded, where the 

415 compile and its refusals happen (decisions/vocabulary-model, 

416 2026-11-02 #754, items 3 and 4). The case masks follow the same 

417 order: the choice moves `lexicon`'s, the packs' are laid over them, 

418 and a mask `lexicon` holds as the choice leaves the shipped one is 

419 no statement of the caller's, so a pack's mask of the word wins it, 

420 as a pack's readings win over the shipped ones.""" 

421 fragments = Lexicon.empty() 

422 for loc in locales: 

423 fragments = fragments | loc.lexicon 

424 joined = fragments | lexicon 

425 if languages: 

426 chosen = _reading_index_selected(languages, strict) 

427 mine = dict(_rechosen_masks(lexicon, chosen, strict)) 

428 shipped = dict(_rechosen_masks(Lexicon.default(), chosen, strict)) 

429 else: 

430 mine = dict(lexicon.capitalization_exceptions) 

431 shipped = dict(Lexicon.default().capitalization_exceptions) 

432 masks = dict(mine) 

433 for k, v in fragments.capitalization_exceptions: 

434 if k not in mine or mine[k] == shipped.get(k): 

435 masks[k] = v 

436 cap = tuple(sorted(masks.items())) 

437 if not languages and cap == joined.capitalization_exceptions: 

438 return joined 

439 return dataclasses.replace( 

440 joined, capitalization_exceptions=cap, _languages=languages, 

441 _strict=strict) 

442 

443 

444def _failed_layer(lexicon: Lexicon, locales: tuple[Locale, ...], 

445 languages: frozenset[str], strict: bool) -> str: 

446 """The layer a failed `_with_packs` names (rules/D2): the first pack 

447 whose fragment fails the join, or, where base's lexicon fails under 

448 the choice alone, the languages and the packs that chose them. 

449 Taken on the error path only, by building again pack by pack.""" 

450 def fails(packs: tuple[Locale, ...]) -> bool: 

451 try: 

452 _with_packs(lexicon, packs, languages, strict) 

453 except (TypeError, ValueError): 

454 return True 

455 return False 

456 

457 if not fails(()): 

458 for n, loc in enumerate(locales, 1): 

459 if fails(locales[:n]): 

460 return f"while applying locale {loc.code!r}" 

461 # base's lexicon fails alone only under the choice: with no 

462 # languages a join fails only on a pack, found above 

463 choosers = [repr(loc.code) for loc in locales if loc.languages] 

464 if lexicon.languages: 

465 choosers.append("base") 

466 return (f"while applying the languages chosen, " 

467 f"{', '.join(sorted(languages))} (by {', '.join(choosers)})") 

468 

469 

470def parser_for(*locales: Locale, base: Parser | None = None, 

471 segmenter: Segmenter | None | _Unset = UNSET, 

472 strict: bool | _Unset = UNSET) -> Parser: 

473 """The packs' ``languages`` and base's recorded choice unioned into 

474 one choice, recorded on the built parser's lexicon 

475 (:attr:`Parser.languages`), so the order of the packs, and whether 

476 they were chained through ``base``, does not change it: 

477 ``parser_for(EN, base=parser_for(PT))`` reads as ``parser_for(PT, 

478 EN)``. The packs' lexicon fragments are joined left-to-right and 

479 base's lexicon after them, so a caller's own readings outrank every 

480 pack's and a later pack's an earlier one's; the choice filters the 

481 shipped readings and every lexicon's ``add`` and ``remove`` apply 

482 after it, so a choice never drops a caller's word. A caller's 

483 reading that cannot stand beside one the choice turns on raises 

484 ValueError here, naming the call (decisions/vocabulary-model, 

485 2026-11-02 #754, items 3 and 4). Policy patches apply left-to-right 

486 (later wins; set-valued fields union per the patch metadata). A 

487 pack that chooses no language leaves the base vocabulary exactly 

488 as it is. 

489 

490 ``strict=True`` makes the choice strict: only the chosen languages' 

491 vocabulary files are compiled, each tag selecting its own and its 

492 ancestors' as a plain choice does, plus mul.toml, the words every 

493 language writes alike (``Dr``, ``Prof``, ``PhD``, ``MSc``, the 

494 numerals), and a native-script word's readings (one with a letter 

495 and no Latin letter), name readings included, as a plain choice of 

496 the same languages keeps them; every other file's readings leave, 

497 und.toml's among them, and with them the markers they derive. It is 

498 for single-origin data (``parser_for(Locale(code="ko", 

499 lexicon=Lexicon.empty(), languages=frozenset({"ko"})), 

500 strict=True)`` reads ``Kim Do Hyun`` 

501 with no Portuguese particle ``do``). A caller's own readings and the 

502 packs' fragments apply after it, as after a plain choice, and the 

503 policy patches unchanged. ``strict`` has three states, as 

504 ``segmenter`` does: omitted (:data:`~nameparser.UNSET`) keeps base's 

505 recorded strictness, ``False`` on a strict base raises ValueError, a 

506 strict choice not being undone by a later call, and ``True`` on a 

507 plain one makes the whole choice strict. A strict choice accepts a 

508 language whose file holds no reading a parse reads yet (``hu``, 

509 whose familiar forms only name matching reads), which a plain 

510 one refuses as changing nothing, and raises ValueError when the 

511 packs choose no language, since it would then keep no language's 

512 own words, only mul.toml's and native-script words' readings 

513 (decisions/vocabulary-model, 2026-10-30 #753, file-strict, and 

514 2026-10-31 #753, phase 3j). 

515 

516 Validation errors raised while applying a 

517 pack are wrapped with that pack's identity (rule D2) -- 

518 PolicyPatch validates lazily, so with stacked packs the raw error 

519 would otherwise point at nothing. Two packs setting the same SCALAR 

520 field to different values is a declared conflict: UserWarning, later 

521 wins; two setting it to the same value agree, and nothing is warned. 

522 

523 A ``segmenter`` is passed straight through to the built Parser -- 

524 ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` is how 

525 a pack and a segmenter combine, since packs are pure data and 

526 cannot supply one. The argument has THREE states, the same 

527 :data:`~nameparser.UNSET` spelling a PolicyPatch field uses, because 

528 None is a meaningful value here and not an absence: omitted (UNSET) 

529 carries base's segmenter through unchanged; a callable OVERRIDES 

530 base's (later wins, the rule scalar policy fields follow); and an 

531 explicit ``None`` CLEARS base's, which is how you derive an 

532 unsegmented parser from a segmented one without rebuilding its 

533 lexicon and policy by hand.""" 

534 if base is not None and not isinstance(base, Parser): 

535 raise TypeError(f"base must be a Parser or None, got {base!r}") 

536 if strict is not UNSET and not isinstance(strict, bool): 

537 raise TypeError(f"strict must be True or False, got {strict!r}") 

538 for loc in locales: 

539 if not isinstance(loc, Locale): 

540 raise TypeError(f"parser_for() takes Locale packs, got {loc!r}") 

541 lexicon = base.lexicon if base is not None else Lexicon.default() 

542 policy = base.policy if base is not None else Policy() 

543 # Resolved here rather than at the return because the return builds 

544 # a FRESH Parser: any field not listed there silently takes its 

545 # default, and a dropped segmenter would be invisible. UNSET, not 

546 # None, is what "not given" means -- None is the CLEAR request, and 

547 # collapsing the two would make an explicit 

548 # parser_for(..., segmenter=None) silently inherit the very 

549 # segmenter it was asked to drop. 

550 if segmenter is UNSET: 

551 segmenter = base.segmenter if base is not None else None 

552 # strict's three states, as segmenter's: omitted keeps base's 

553 # strictness; an explicit False on a strict base is refused, a 

554 # strict choice not being undone by a later call, and an explicit 

555 # True makes a plain base's choice strict (decisions/vocabulary-model, 

556 # 2026-11-02 #754, sub-phase 4i-c) 

557 if isinstance(strict, _Unset): 

558 strict = lexicon.strict 

559 elif lexicon.strict and not strict: 

560 raise ValueError( 

561 "base's lexicon was chosen with strict=True, and a chained " 

562 "choice keeps it: leave strict out or pass strict=True, or " 

563 "choose every language in one call from the default lexicon, " 

564 "base's packs included, parser_for(*packs, strict=False)") 

565 # one choice for all the packs and the base's, so applying them is 

566 # order-free; each pack's tags are checked on their own first, so a 

567 # tag no file holds is reported with the pack that named it 

568 # (rules/D2) 

569 languages = lexicon.languages 

570 for loc in locales: 

571 if loc.languages: 

572 try: 

573 _reading_index_selected(loc.languages, strict) 

574 except ValueError as exc: 

575 raise _rewrapped( 

576 exc, f"while applying locale {loc.code!r}") from exc 

577 languages |= loc.languages 

578 if strict and not languages: 

579 raise ValueError(_no_strict_language(locales)) 

580 try: 

581 lexicon = _with_packs(lexicon, locales, languages, strict) 

582 except (TypeError, ValueError) as exc: 

583 # rules/D2: "applying a locale pack wraps any such error with 

584 # the locale's code, so a stacked configuration names which 

585 # layer broke" -- the pack whose fragment broke the join, or, 

586 # where base's own lexicon clashes with the choice, the 

587 # languages and the packs that chose them (history: 

588 # decisions/vocabulary-model, 2026-10-29 3b and 2026-11-02 #754, 

589 # sub-phase 4i-c) 

590 raise _rewrapped(exc, _failed_layer( 

591 lexicon, locales, languages, strict)) from exc 

592 # each scalar field's setter so far and the value it left in the 

593 # policy: a later pack leaving another value overrides it, which is 

594 # warned; one leaving the same value (cs and sk's two_family_names) 

595 # agrees. Compared as the applied Policy holds it, canonical, so a 

596 # mapping written in another key order (script_orders) is the value 

597 # it is, not the spelling it was given in. 

598 scalar_setters: dict[str, tuple[str, object]] = {} 

599 scalars = [f.name for f in dataclasses.fields(PolicyPatch) 

600 if f.metadata.get("compose") != "union"] 

601 for loc in locales: 

602 # rules/D2: "applying a locale pack wraps any such error with 

603 # the locale's code, so a stacked configuration names which layer 

604 # broke" (history: decisions/D2) 

605 try: 

606 policy = apply_patch(policy, loc.policy) 

607 except (TypeError, ValueError) as exc: 

608 raise _rewrapped(exc, f"while applying locale {loc.code!r}") from exc 

609 for name in scalars: 

610 if getattr(loc.policy, name) is UNSET: 

611 continue 

612 value = getattr(policy, name) 

613 if name in scalar_setters: 

614 code, held = scalar_setters[name] 

615 if code != loc.code and held != value: 

616 warnings.warn( 

617 f"locale {loc.code!r} overrides scalar policy field " 

618 f"{name!r} already set by locale {code!r}; later wins", 

619 UserWarning, stacklevel=2) 

620 scalar_setters[name] = (loc.code, value) 

621 # rules/D1: "constructing a parser that activates division for 

622 # scripts with no covering surnames and no segmenter warns at 

623 # construction, naming the dead scripts and each way out" 

624 # (history: decisions/D1) 

625 # Construction warnings (the segmenterless-activation check in 

626 # Parser.__post_init__) re-emit from THIS frame: its stacklevel is 

627 # sized for direct Parser(...) construction, and through this 

628 # function's extra frame the default single-line rendering would 

629 # point into the library instead of at the caller -- the exact 

630 # call the message tells them to change. 

631 with warnings.catch_warnings(record=True) as caught: 

632 warnings.simplefilter("always") 

633 built = Parser(lexicon=lexicon, policy=policy, segmenter=segmenter) 

634 for w in caught: 

635 warnings.warn(w.message, stacklevel=2) 

636 return built 

637 

638 

639def _no_strict_language(locales: tuple[Locale, ...]) -> str: 

640 """The refusal of a strict call that chooses no language, which 

641 would keep only mul.toml's and native-script words' readings, dropping 

642 every language's own words in silence. The fix it offers is a pack 

643 choosing the first pack's code where that is a language a strict 

644 choice accepts, else ``en``.""" 

645 tag = "en" 

646 if locales: 

647 try: 

648 _reading_index_selected(frozenset({locales[0].code}), True) 

649 except ValueError: 

650 pass 

651 else: 

652 tag = locales[0].code 

653 codes = ", ".join(repr(loc.code) for loc in locales) 

654 named = ("no pack was given" if not locales 

655 else f"the pack {codes} chooses none" if len(locales) == 1 

656 else f"the packs {codes} choose none") 

657 return (f"strict=True keeps only the chosen languages' vocabulary, " 

658 f"the shared words (mul.toml) and other scripts' words, and " 

659 f"{named}, so it would keep the shared and other scripts' " 

660 f"words alone; add " 

661 f"a pack that chooses a language, e.g. " 

662 f"Locale(code={tag!r}, lexicon=Lexicon.empty(), " 

663 f"languages=frozenset({{{tag!r}}})), or drop strict=True")