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

73 statements  

1"""The Locale type: a named delta over (Lexicon, Policy), with a 

2choice of languages. 

3 

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). 

12 

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 

18 

19import dataclasses 

20import re 

21from collections.abc import Callable, Iterable, Mapping 

22from dataclasses import dataclass, field 

23from typing import Any 

24 

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 

31 

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})*") 

36 

37 

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.""" 

48 

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"}) 

72 

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

74 __getstate__ = _guarded_getstate 

75 __setstate__ = _guarded_setstate 

76 

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)) 

107 

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})" 

120 

121 

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) 

148 

149 

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} 

164 

165 

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 

174 

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] 

184 

185 

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 

202 

203 

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