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
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"""Rendering for the 2.0 API: ParsedName -> display strings.
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.
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
15import re
17from nameparser._lexicon import Lexicon, _normalize
18from nameparser._types import (FOLDED_TAG, UNCLASSIFIED_TAG, UNJOINED_TAG,
19 Ambiguity, ParsedName, Role, Token)
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|\.)+")
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
32#: str.format keys initials() accepts: the three name-bearing roles.
33_INITIALS_KEYS = (Role.GIVEN.value, Role.MIDDLE.value, Role.FAMILY.value)
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"})
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])$")
65def _reads_as_conjunction(word: str, lex: Lexicon) -> bool:
66 """v1's is_conjunction, asked only of text the parse never saw.
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))
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(", ")
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)
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)
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)
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()
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)
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)