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
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
1"""Parser and the module-level parse() for the 2.0 API.
3Layering: sits on _types/_lexicon/_policy/_locale/_pipeline; never
4imports _render (enforced by tests/v2/test_layering.py).
6_default_parser is THE one sanctioned module-level global: a
7functools.cache'd frozen Parser over default config.
8"""
9from __future__ import annotations
11import dataclasses
12import functools
13import warnings
14from collections.abc import Iterable
15from dataclasses import dataclass, field
16from typing import TYPE_CHECKING
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)
32if TYPE_CHECKING:
33 from nameparser._match import Matching, Outcome
34 from nameparser._suggest import Suggestion
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.
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.
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.)"""
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)
74 # in the class body so @dataclass(slots=True) keeps them
75 __getstate__ = _guarded_getstate
76 __setstate__ = _guarded_setstate
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)
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})"
148 # -- properties ------------------------------------------------------
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
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
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))
182 # -- editing ----------------------------------------------------------
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)
269 # -- comparison -------------------------------------------------------
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()
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)
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.
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)
369 # -- rendering delegates ----------------------------------------------
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)
381@functools.cache
382def _default_parser() -> Parser:
383 return Parser()
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)
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)
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}")
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)
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
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)})")
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.
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).
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.
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
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")