Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_locale.py: 37%
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"""The Locale type: a named delta over (Lexicon, Policy), with a
2choice of languages.
4A Locale dissolves at parser construction (parser_for): the packs'
5languages are unioned with the base's recorded choice into one choice,
6recorded on the built Lexicon, which selects among the base's shipped
7readings by the file each sits in; the lexicon fragments join in pack
8order and the base's lexicon after them, their readings applying after
9the choice; and the PolicyPatch folds via apply_patch. Packs
10are pure data; they have no privileged capabilities
11(mechanisms.md#LOCALE-PACKS-PURE-DATA).
13Layering: imports _types, _lexicon and _policy only, and the compiled
14[rules] tables, nameparser/config/_rules.py, at call time (enforced by
15tests/v2/test_layering.py).
16"""
17from __future__ import annotations
19import dataclasses
20import re
21from collections.abc import Callable, Iterable, Mapping
22from dataclasses import dataclass, field
23from typing import Any
25from nameparser._lexicon import Lexicon, _canonical, _shown_choice
26from nameparser._policy import (
27 _ORDER_CONSTANT_NAMES, UNSET, PatronymicRule, PolicyPatch,
28 _classified_char, _reject_str_and_mapping,
29)
30from nameparser._types import ADDED_IN, _guarded_getstate, _guarded_setstate
32# a BCP 47 tag's shape: a language subtag and any number of subtags
33# after it; whether a vocabulary file holds it is parser_for's question,
34# asked of the reading index when the choice is applied
35_TAG = re.compile(r"[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*")
38@dataclass(frozen=True, slots=True)
39class Locale:
40 """A named, shareable bundle of vocabulary and behavior for a
41 naming tradition: a choice of languages, a lexicon fragment and a
42 policy patch, applied together by :func:`nameparser.parser_for`.
43 The packs shipped with
44 nameparser live in :mod:`nameparser.locales`; building your own
45 needs no registration -- construct one and pass it to
46 ``parser_for``. See the :doc:`locale packs guide </locales>` for
47 using, creating, and contributing packs."""
49 #: Identifier, lowercase ``[a-z0-9_]+`` (e.g. "ru", "tr_az").
50 code: str
51 #: Vocabulary joined to the base parser's lexicon, before it, so the
52 #: base's own readings win where the two disagree, and applied after
53 #: the choice in ``languages``, which selects among the base's
54 #: shipped readings (decisions/vocabulary-model, 2026-11-02 #754,
55 #: item 3).
56 lexicon: Lexicon
57 #: Behavior changes folded onto the base policy (set-valued fields
58 #: union; scalars override, later pack wins).
59 policy: PolicyPatch = PolicyPatch()
60 #: The languages chosen, as BCP 47 tags (``{"pt"}``, ``{"pt-BR"}``):
61 #: ``parser_for`` unions the packs' languages, and the base's recorded
62 #: choice, into one choice, which
63 #: turns on the chosen languages' readings the default leaves off
64 #: and, on a word a chosen language holds a vocabulary reading of,
65 #: drops other languages' vocabulary readings. A tag selects its own
66 #: vocabulary file and its ancestors' (``pt-BR`` selects ``pt``
67 #: too), never its descendants'. Empty, the default, chooses
68 #: nothing, and the default vocabulary stands as it is. Added in
69 #: 3.0.
70 languages: frozenset[str] = field(
71 default=frozenset(), metadata={ADDED_IN: "3.0.0"})
73 # in the class body so @dataclass(slots=True) keeps them
74 __getstate__ = _guarded_getstate
75 __setstate__ = _guarded_setstate
77 def __post_init__(self) -> None:
78 if not isinstance(self.code, str):
79 raise TypeError(
80 f"Locale.code must be a str, got {self.code!r}"
81 )
82 if not self.code.strip():
83 raise ValueError(
84 f"Locale.code must be a non-empty string, got {self.code!r}"
85 )
86 if self.code != self.code.lower():
87 raise ValueError(
88 f"Locale.code must be lowercase, got {self.code!r}"
89 )
90 # Codes are registry keys (parser_for, third-party packs): every
91 # accepted character is supported forever, so pin the charset
92 # while relaxing later is still compatible. One separator only --
93 # allowing '-' too would make tr-az and tr_az distinct keys.
94 if not re.fullmatch(r"[a-z0-9_]+", self.code):
95 raise ValueError(
96 f"Locale.code must match [a-z0-9_]+, got {self.code!r}"
97 )
98 if not isinstance(self.lexicon, Lexicon):
99 raise TypeError(
100 f"Locale.lexicon must be a Lexicon, got {self.lexicon!r}"
101 )
102 if not isinstance(self.policy, PolicyPatch):
103 raise TypeError(
104 f"Locale.policy must be a PolicyPatch, got {self.policy!r}"
105 )
106 object.__setattr__(self, "languages", _tags(self.languages))
108 def __repr__(self) -> str:
109 # Bounded: shows the code and which Policy fields the patch sets,
110 # never the Lexicon contents or the patched values themselves
111 # (design rule, see nameparser._types module docstring).
112 patched = [f.name for f in dataclasses.fields(self.policy)
113 if getattr(self.policy, f.name) is not UNSET]
114 # a choice is shown by its tags while it is short, as one is
115 # (a pack chooses one or two), and by its size past that
116 if self.languages:
117 patched.insert(0, _shown_choice(self.languages))
118 suffix = f": {', '.join(patched)}" if patched else ""
119 return f"Locale({self.code!r}{suffix})"
122def _tags(value: object) -> frozenset[str]:
123 """`value` as Locale.languages stores it, each tag in canonical case,
124 or the TypeError or ValueError naming what is wrong with it."""
125 if isinstance(value, Mapping):
126 # not _reject_str_and_mapping's message: its {open: close} hint
127 # is about delimiter pairs, which a choice of languages never is
128 raise TypeError(
129 f"Locale.languages must be a set of BCP 47 tags, not a "
130 f"mapping: {value!r}. Write frozenset({{'pt'}}), or "
131 f"frozenset(mapping) to choose its keys")
132 _reject_str_and_mapping(value, "Locale.languages")
133 if not isinstance(value, Iterable):
134 raise TypeError(
135 f"Locale.languages must be an iterable of BCP 47 tags, e.g. "
136 f"frozenset({{'pt'}}), got {value!r}")
137 tags = frozenset(value)
138 for tag in sorted(tags, key=repr):
139 if not isinstance(tag, str):
140 raise TypeError(
141 f"Locale.languages entries must be str BCP 47 tags, got "
142 f"{tag!r}")
143 if not _TAG.fullmatch(tag):
144 raise ValueError(
145 f"Locale.languages entries must be BCP 47 tags such as 'pt' "
146 f"or 'pt-BR', got {tag!r}")
147 return frozenset(_canonical(tag) for tag in tags)
150#: How a compiled [rules] value becomes its PolicyPatch field's value,
151#: by key: an order by its constant's name written in the file's case
152#: ("family-first" is FAMILY_FIRST), a rule set by PatronymicRule's
153#: values. tools/vocab/build.py's RULE_KEYS checks the names at build
154#: time, and tests/v2/test_vocab.py holds the two tables to each other.
155_ORDERS = {name.lower().replace("_", "-"): order
156 for order, name in _ORDER_CONSTANT_NAMES.items()}
157_RULE_VALUES: dict[str, Callable[[Any], object]] = {
158 "name_order": lambda v: _ORDERS[v],
159 "patronymic_rules": lambda v: frozenset(PatronymicRule(r) for r in v),
160 "two_family_names": bool,
161 "two_given_names": bool,
162 "initials_as_family": bool,
163}
166def rules_patch(tag: str) -> PolicyPatch:
167 """The policy patch compiled from the [rules] table of the
168 vocabulary file `tag` (``"ru"`` for ru.toml), which a shipped pack
169 carries as its ``policy`` (#754, 4f): each key is the Policy field
170 of the same name, so a rule a pack turns on is a Policy field and a
171 Locale holds no rules of its own. Raises KeyError naming the files
172 that hold a table when `tag`'s holds none."""
173 from nameparser.config._rules import RULES
175 tables = dict(RULES)
176 if tag not in tables:
177 raise KeyError(
178 f"no vocabulary file {tag!r} holds a [rules] table; the files "
179 f"that do: {', '.join(sorted(tables))}")
180 values = {key: _RULE_VALUES[key](value) for key, value in tables[tag]}
181 # Known mypy limitation with **dict-unpacked construction; see the
182 # full explanation at Lexicon._edit in _lexicon.py.
183 return PolicyPatch(**values) # type: ignore[arg-type]
186def deviates_by_two_family_names(name: str) -> bool:
187 """The DEVIATES declaration of a pack whose patch turns on
188 ``Policy.two_family_names`` (rules/O6), the es, es_ar, cs and sk
189 packs, and of the given-name list each Spanish pack carries beside
190 it, which acts only where O6 does. O6 acts only on a name of three
191 or more name words, so a name declares that holds three or more
192 words, commas read as spaces,
193 or three or more units counting each run of letters and each word
194 holding no letter: the tokenizer may split one word into two runs
195 (a zero-width space, a Hangul name segmented), and a word of no
196 letter (``1``, ``-``) is a name word all the same. Titles, suffixes
197 and a family comma count here though O6 reads none of them, which
198 over-declares, the safe direction."""
199 pieces = name.replace(",", " ").split()
200 letterless = sum(1 for piece in pieces if not any(ch.isalpha() for ch in piece))
201 return len(pieces) >= 3 or _letter_runs(name) + letterless >= 3
204def _letter_runs(text: str) -> int:
205 """The runs of letters in `text`, whatever separates one from the
206 next: a hyphen, a zero-width space or an interpunct, which the
207 tokenizer may read as separators (rules/T1, T2, T3). A run opening
208 with a character of a script the script table classifies counts
209 twice, since segmentation may divide it into a family and a given
210 name (rules/W1): the table every script-conditional rule reads, so
211 a character W1 can divide is one this counts twice, and one outside
212 it (a compatibility jamo, an archaic kana) is a word W1 never divides
213 (#1087). A letter written with a combining mark splits a run, which
214 over-declares."""
215 runs, inside = 0, False
216 for ch in text:
217 letter = ch.isalpha()
218 if letter and not inside:
219 runs += 2 if _classified_char(ch) else 1
220 inside = letter
221 return runs