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

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"""Rendering for the 2.0 API: ParsedName -> display strings. 

2 

3Layering: imports nameparser._types, and nameparser._lexicon for 

4Lexicon.default() (capitalized() with lexicon=None) and _normalize 

5(enforced by tests/v2/test_layering.py). Parsing code never imports 

6this module; ParsedName's rendering methods delegate here via 

7call-time imports. 

8 

9Malformed str.format specs beyond unknown keys (positional fields, 

10bad conversions) surface the raw str.format error; only unknown KEYS 

11get the enriched KeyError. 

12""" 

13from __future__ import annotations 

14 

15import re 

16 

17from nameparser._lexicon import Lexicon, _normalize 

18from nameparser._types import (FOLDED_TAG, UNCLASSIFIED_TAG, UNJOINED_TAG, 

19 Ambiguity, ParsedName, Role, Token) 

20 

21_SPACES = re.compile(r"\s+") 

22_SPACE_BEFORE_COMMA = re.compile(r"\s+,") 

23_COMMA_CHAR = re.compile(r"[,،,]") # ASCII, Arabic, fullwidth 

24_MAC = re.compile(r"^(ma?c)(\w{2,})", re.IGNORECASE) 

25_WORD = re.compile(r"(\w|\.)+") 

26 

27#: str.format keys render() accepts: the seven role fields in canonical 

28#: order (derived from Role -- never restated) plus the derived views. 

29_DERIVED_VIEWS = ("family_base", "family_particles", "surnames", "given_names") 

30_RENDER_KEYS = tuple(r.value for r in Role) + _DERIVED_VIEWS 

31 

32#: str.format keys initials() accepts: the three name-bearing roles. 

33_INITIALS_KEYS = (Role.GIVEN.value, Role.MIDDLE.value, Role.FAMILY.value) 

34 

35#: Tags whose tokens contribute no initial outside the given group -- 

36#: unless the token also carries UNJOINED_TAG, i.e. the whole part is 

37#: particles, in which case they are the part's only words and do 

38#: contribute (rules.md#R3, #404). The mark readmits a token carrying 

39#: EITHER tag: a conjunction with nothing to join is not acting as a 

40#: conjunction any more than a particle with nothing to join is acting 

41#: as a particle, so it is a name word of the part like the rest. 

42#: Not STABLE_TAGS -- that also contains "initial", which must contribute. 

43_SKIP_TAGS = frozenset({"particle", "conjunction"}) 

44 

45# Ported verbatim from v1 (nameparser/config/regexes.py "initial", minus 

46# the empty alternative) -- layering forbids importing the pipeline here; 

47# keep in sync with _pipeline/_vocab.py by hand. 

48# Its one reader is _reads_as_conjunction below, and that reader only 

49# ever sees text the parse never classified: for anything the 

50# parser DID see, the tag is the answer and this pattern is not asked. 

51# So the two copies no longer decide the same question about the same 

52# token -- _vocab's says what the parse decided, this one says what it 

53# WOULD have decided about text spliced in afterwards -- which is why 

54# they must keep answering alike, and why test_regex_sync pins the 

55# patterns against each other and against config. 

56# Deliberately NOT composed with _vocab's repertoire test (#320): 

57# layering forbids the import. The divergence is reachable only for a 

58# caller-added CJK conjunction spliced into a field, since no shipped 

59# vocabulary carries one, and it costs nothing there: CJK is caseless, 

60# so the carve-out's lower() and the fall-through's capitalize() return 

61# the same string, and case repair is now this pattern's only reader. 

62_INITIAL = re.compile(r"^(\w\.|[A-Z])$") 

63 

64 

65def _reads_as_conjunction(word: str, lex: Lexicon) -> bool: 

66 """v1's is_conjunction, asked only of text the parse never saw. 

67 

68 A token the parse classified carries its reading in its tags and 

69 this is not consulted. A token carrying UNCLASSIFIED_TAG was 

70 spliced into a field as raw text -- by replace(), or by the 

71 facade's v1 pickle load -- and carries no reading, so case repair 

72 falls back to the vocabulary, which gives the answer the parser 

73 would have given, the initial carve-out included ('E.' assigned to 

74 middle is an initial, not the Italian conjunction). 

75 """ 

76 return bool(_normalize(word) in lex.conjunctions 

77 and not _INITIAL.fullmatch(word)) 

78 

79 

80def _collapse(rendered: str) -> str: 

81 """The #254 collapse: empty fields substitute '' and every artifact 

82 of that is removed -- dangling empty-nickname wrappers, space runs, 

83 space-before-comma, one trailing comma character (any script), 

84 leading/trailing ', ' debris.""" 

85 rendered = (rendered.replace(" ()", "") 

86 .replace(" ''", "") 

87 .replace(' ""', "")) 

88 rendered = _SPACE_BEFORE_COMMA.sub(",", rendered) 

89 rendered = _SPACES.sub(" ", rendered.strip()) 

90 if rendered and _COMMA_CHAR.fullmatch(rendered[-1]): 

91 rendered = rendered[:-1] 

92 return rendered.strip(", ") 

93 

94 

95def _format_spec(spec: str, values: dict[str, str], noun: str, 

96 keys: tuple[str, ...]) -> str: 

97 """Shared tail of render()/initials(): fill the spec, enrich 

98 unknown-KEY errors with the valid key list, collapse.""" 

99 if not isinstance(spec, str): 

100 raise TypeError(f"spec must be a str, got {spec!r}") 

101 try: 

102 rendered = spec.format(**values) 

103 except KeyError as exc: 

104 raise KeyError( 

105 f"unknown {noun} field {exc.args[0]!r}; valid fields: " 

106 f"{', '.join(keys)}" 

107 ) from None 

108 return _collapse(rendered) 

109 

110 

111def render(name: ParsedName, spec: str) -> str: 

112 """Fill the str.format spec from the seven role fields and the 

113 derived views (empty fields substitute ''), then apply the #254 

114 collapse. Unknown keys raise KeyError naming the valid fields.""" 

115 values = {key: getattr(name, key) for key in _RENDER_KEYS} 

116 return _format_spec(spec, values, "render", _RENDER_KEYS) 

117 

118 

119# rules.md#R3: "initials take the first letter of each given, middle, 

120# and base family word; titles, suffixes, particles and nicknames 

121# contribute nothing" 

122def initials(name: ParsedName, spec: str, delimiter: str, separator: str) -> str: 

123 """First letter of each contributing token per group, v1 semantics: 

124 delimiter follows each initial, separator sits between initials 

125 within a group. Each group is ordered the way its FIELD is 

126 ordered -- written order, except folded words, which initial 

127 before the rest of the group (#408). Tokens tagged 

128 particle/conjunction contribute no 

129 initial in middle/family (given-name tokens always contribute), 

130 and the unjoined mark readmits the words of an all-particle part 

131 whichever of those tags they carry; tags come from the pipeline -- 

132 hand-built untagged tokens all contribute, and so do the words of 

133 a field spliced in by replace(), which the parse never read. 

134 This view takes NO lexicon, so it has none to fall back to for 

135 that text: `replace(family='de la vega')` initials every word of 

136 that field where the same name parsed gives 'j. v.' 

137 (rules.md#R3's Accepted 

138 clause, and decisions.md#R4 for why the fallback was tried 

139 and dropped -- #464 is the crossing that would make it 

140 answerable). Valid spec keys: given, middle, family.""" 

141 if not isinstance(delimiter, str): 

142 raise TypeError(f"delimiter must be a str, got {delimiter!r}") 

143 if not isinstance(separator, str): 

144 raise TypeError(f"separator must be a str, got {separator!r}") 

145 values: dict[str, str] = {} 

146 for key in _INITIALS_KEYS: 

147 role = Role(key) 

148 tokens = name.tokens_for(role) 

149 if role is not Role.GIVEN: 

150 tokens = tuple(t for t in tokens 

151 if not (_SKIP_TAGS & t.tags) 

152 or UNJOINED_TAG in t.tags) 

153 # mechanisms.md#FOLDED_TAG: "a rule that needs different 

154 # rendering order tags the token, and the rendering views 

155 # consult the tag" -- this is a rendering view, so it reads 

156 # the tag the same way _types._text_for does, and for the same 

157 # reason: the fold is an ORDER the parse recorded, not one the 

158 # view is free to take again 

159 # (mechanisms.md#RENDER-HONORS-THE-PARSE: "the parse decides 

160 # it; the render views honor those decisions and never 

161 # re-evaluate them"). Applied to every role this view renders, 

162 # exactly as _text_for applies it -- the pipeline puts the tag 

163 # on FAMILY tokens alone today, so GIVEN and MIDDLE are 

164 # uniformity with the mechanism rather than reachable 

165 # behavior; a producer that ever folds into another part would 

166 # otherwise reopen #408 there. 

167 tokens = (tuple(t for t in tokens if FOLDED_TAG in t.tags) 

168 + tuple(t for t in tokens if FOLDED_TAG not in t.tags)) 

169 values[key] = separator.join( 

170 t.text[0] + delimiter for t in tokens) 

171 return _format_spec(spec, values, "initials", _INITIALS_KEYS) 

172 

173 

174def _cap_word(word: str, role: Role, tags: frozenset[str], 

175 lex: Lexicon) -> str: 

176 # v1 cap_word order: particle/conjunction rule first, then the 

177 # exceptions map, then Mac/Mc, then str.capitalize 

178 normalized = _normalize(word) 

179 # rules.md#R4: "a part whose every word is particle vocabulary is 

180 # repaired as ordinary name words, since none of them is doing a 

181 # particle's work there" -- UNJOINED_TAG is that mark (#407). 

182 # Only the PARTICLE conjunct is gated on it, and that is the rule 

183 # rather than an omission -- rules.md#R4: "A CONJUNCTION keeps its 

184 # lowercase even inside such a part, being no name word in any 

185 # part" -- so a conjunction keeps conjunction treatment even 

186 # inside a part the mark has turned into ordinary name words. 

187 # No SHIPPED name witnesses the difference: `particles` and 

188 # `conjunctions` are disjoint in the default vocabulary and in 

189 # every locale pack, so no shipped conjunction can sit in an 

190 # all-particle part and carry the mark. That is a property of the 

191 # shipped DATA, not an invariant -- both sets are public, 

192 # configurable API, and a caller's Lexicon may put one word in 

193 # both, the way _pipeline/_post_rules.py's arms allow for. Measured: 

194 # under `Lexicon.default().add(particles={'y'})`, `anh y van` has 

195 # an all-particle family whose `y` carries both tags and the mark, 

196 # and gives 'Anh y Van'; gating this conjunct too would give 

197 # 'Anh Y Van'. That is pinned by test_repair_keeps_a_conjunction_ 

198 # lowercase_in_a_particle_part -- until which gating it passed the 

199 # whole suite. 

200 # initials() does NOT match this carve-out, and the mismatch is 

201 # recorded rather than fixed: #461 made it match and was backed 

202 # out, the mark being a statement about a whole PART that #461 

203 # honored for some of the part's words and not for one of them, 

204 # so what is in question is R3's "even then" clause rather than 

205 # the code (decisions.md, under R2). Under that same lexicon 

206 # `Anh y Van` repairs to 'Anh y Van' and initials 'A. y. V.', 

207 # pinned by 

208 # test_initials_readmits_a_conjunction_in_a_particle_part. 

209 # That conjunct reads the TAG, not the word (#458). classify takes 

210 # the conjunction-versus-initial decision once, over the whole 

211 # token -- v1's is_conjunction excludes initials, so 'E.' in 

212 # 'Scott E. Werner' is an initial and is never tagged (pinned live 

213 # 2026-07-17) -- and a view honors that decision rather than 

214 # taking it again from the spelling 

215 # (mechanisms.md#RENDER-HONORS-THE-PARSE: "the render views honor 

216 # those decisions and never re-evaluate them"), the tags being 

217 # classify's record of it (mechanisms.md#VOCAB-TAGS: "later stages 

218 # test tags"). Asking again was not even the same question: 

219 # the copy of the initial pattern that stood here was the SHAPE 

220 # half alone, and it re-decided per WORD of a token's text, so 

221 # 'juan e-f smith' capitalized to 'Juan e-F Smith'. 

222 # mechanisms.md#RENDER-HONORS-THE-PARSE: "a token the parse never 

223 # saw carries no decision to honor, so a view falls back to the 

224 # vocabulary" -- _reads_as_conjunction above, which is v1's 

225 # predicate applied over TODAY's vocabulary rather than 1.4.0's. 

226 # That is the honest claim and it is narrower than parity: the two 

227 # vocabularies differ, so an assigned field can repair differently 

228 # from 1.4.0 without this predicate differing at all. Measured on 

229 # the released wheel: `h.last = "хосе и мария сантос"` gives 

230 # 'Хосе И Мария Сантос' on 1.4.0 and 'Хосе и Мария Сантос' here, 

231 # the Cyrillic `и` being a 2.x conjunction and not a 1.4.0 one; 

232 # `h.last = "de la vega"` gives 'de la Vega' there and here. 

233 # The mark, not the SPAN, is what says the text was never read: 

234 # Parser.revise() also builds span-less tokens, from a sub-parse 

235 # whose tags it keeps on purpose, and keying this on `span is None` 

236 # overrode them -- `revise(middle='e-f')` repaired to 'e-F' where 

237 # the same words parsed gave 'E-F' (#463 review). 

238 if ((normalized in lex.particles and role in (Role.MIDDLE, Role.FAMILY) 

239 and UNJOINED_TAG not in tags) 

240 or "conjunction" in tags 

241 or (UNCLASSIFIED_TAG in tags 

242 and _reads_as_conjunction(word, lex))): 

243 return word.lower() 

244 # v1 cap_word tries the edge-stripped form, then the period-free 

245 # form ('Ph.D.' -> 'ph.d' -> 'phd' hits the exceptions map) 

246 for key in (normalized, normalized.replace(".", "")): 

247 exception = lex.capitalization_exceptions_map.get(key) 

248 if exception is not None: 

249 return exception 

250 if _MAC.match(word): 

251 return _MAC.sub( 

252 lambda m: m.group(1).capitalize() + m.group(2).capitalize(), 

253 word) 

254 return word.capitalize() 

255 

256 

257def _cap_text(text: str, role: Role, tags: frozenset[str], 

258 lex: Lexicon) -> str: 

259 # word-by-word within the token text: hyphenated names capitalize 

260 # both sides ("macdole-eisenhower" -> "MacDole-Eisenhower"). The 

261 # per-word walk is also why an UNCLASSIFIED token gets the 

262 # vocabulary asked per word: the parse would have made one token 

263 # per word of that text, so this is the granularity its answer 

264 # would have had. 

265 return _WORD.sub(lambda m: _cap_word(m.group(0), role, tags, lex), text) 

266 

267 

268# rules.md#R4: "case repair returns a repaired copy and never mutates 

269# the parse" 

270def capitalized(name: ParsedName, lexicon: Lexicon | None, *, 

271 force: bool) -> ParsedName: 

272 """Case-fixing transform -> new ParsedName, same spans, new token 

273 texts. Gate (v1 parity): only single-case input is 

274 touched unless force=True; the gate reads the joined token texts 

275 (not render() output -- the case gate stays decoupled from spec 

276 formatting and the #254 collapse). 

277 The repair reads token TAGS as well as texts: a part whose every 

278 word is particle vocabulary is repaired as ordinary name words, 

279 and the mark saying so comes from the pipeline, as does the 

280 reading that a word is a conjunction rather than an initial. A 

281 token carrying UNCLASSIFIED_TAG -- replace() splices those in, and 

282 so does the facade's v1 pickle load -- was never read: the 

283 vocabulary answers the per-word conjunction question for it, and 

284 the per-part particle question is left to plain particle treatment, 

285 since re-deriving the part answer needs a tag on every word of the 

286 part and these have none. A family set that way to 'de la' stays 

287 'de la' where the same words parsed give 'De La'; one set to 

288 'de y' keeps the 'y' lowercase, as the parse does and as 1.4.0 

289 did. Parser.revise() is the edit that classifies the value, and 

290 gives 'De La' (rules.md#R4's Accepted boundary). 

291 Idempotent: without force, a capitalized result is mixed-case and 

292 the gate returns it unchanged; with force, every _cap_word rule is 

293 a fixpoint on its own output.""" 

294 if lexicon is not None and not isinstance(lexicon, Lexicon): 

295 # eager, before the gate: a garbage argument must not become a 

296 # silent no-op on mixed-case input or a deep AttributeError 

297 raise TypeError(f"lexicon must be a Lexicon or None, got {lexicon!r}") 

298 lex = Lexicon.default() if lexicon is None else lexicon 

299 joined = " ".join(t.text for t in name.tokens) 

300 # rules.md#R5: "case repair acts only on a name written entirely 

301 # in one case" 

302 if not force and joined not in (joined.upper(), joined.lower()): 

303 return name 

304 new_tokens = tuple( 

305 Token(_cap_text(t.text, t.role, t.tags, lex), t.span, t.role, t.tags) 

306 for t in name.tokens) 

307 # equal tokens (possible only for synthetic span=None duplicates) 

308 # collapse to one mapping entry -- benign: the rebuilt ambiguity 

309 # references an equal token, so the subset invariant still holds 

310 replacement = dict(zip(name.tokens, new_tokens)) 

311 new_ambiguities = tuple( 

312 Ambiguity(a.kind, a.detail, 

313 tuple(replacement[t] for t in a.tokens)) 

314 for a in name.ambiguities) 

315 return ParsedName(original=name.original, tokens=new_tokens, 

316 ambiguities=new_ambiguities)