1"""Not a stage: converts the final ParseState into a public ParsedName.
2
3Consumes: tokens (all roles set), dropped, ambiguities (by index).
4Produces: a validated ParsedName -- the constructor re-checks every
5invariant (span order/bounds, ambiguity subset), so a pipeline bug
6that would produce an invalid result dies HERE, not in a renderer
7three layers away.
8
9Structural tokens (dropped maiden markers) are omitted, like delimiter
10characters. A main-stream token that somehow reaches here with no role
11takes Role.GIVEN -- parse must never raise on the *content* of a name,
12so the fallback is deliberately boring rather than an exception. This
13should never fire in practice (assign/group must set a role on every
14main-stream token); it exists as a last-resort safety net against a
15pipeline bug, not as sanctioned behavior for real input -- see
16test_assemble_falls_back_to_given_for_unassigned_role in
17tests/v2/pipeline/test_assemble.py.
18"""
19from __future__ import annotations
20
21from nameparser._pipeline._state import ParseState
22from nameparser._types import Ambiguity, ParsedName, Role, Token
23
24
25# rules.md#A2: "a name with no name content parses to the empty
26# name — every field empty, false as a boolean — while ambiguities
27# born from its punctuation survive on the empty result"
28# (history: decisions.md#A2)
29def assemble(state: ParseState) -> ParsedName:
30 dropped = set(state.dropped)
31 final: dict[int, Token] = {}
32 for i, t in enumerate(state.tokens):
33 if i in dropped:
34 continue
35 role = t.role if t.role is not None else Role.GIVEN
36 final[i] = Token(t.text, t.span, role, t.tags)
37 # No alphanumeric character among the SURVIVING tokens means no
38 # name: a bare '.' or '- -' is not a person. v1 kept such input
39 # (parse('.') -> first '.'); 2.0 empties it so bool() stays an
40 # honest "did I get a name?" check. isalnum() is Unicode-aware, so
41 # every real name in any script has content and only pure
42 # punctuation/symbols empty out. (Embedded junk in a name with
43 # content -- 'John . Smith' -- is left alone: that parse is truthy,
44 # so bool() is not misled.)
45 #
46 # This read "anywhere" until #329, and the two said the same thing
47 # for as long as nothing could take a content-bearing token away.
48 # Dropping a maiden marker inside a delimited clause can, and
49 # "(née —)" under Policy(maiden_delimiters=...) is the input where
50 # the readings part: the marker is the clause's only alnum token,
51 # so once it goes structural the em dash is all that survives and
52 # the whole parse empties -- where 1.4.0 and pre-#329 both gave
53 # maiden 'née —'. Deliberate, not fallout: a dropped marker is
54 # structural like a delimiter character, and brackets plus a marker
55 # plus a dash name no one, exactly as "(-)" already named no one.
56 # A guard keyed on what ELSE is in the clause would be a different
57 # rule, and would leave maiden holding marker-plus-punctuation.
58 # Pinned by cases.py's maiden_marker_delimited_content_free and,
59 # for the other side of it -- the same clause inside a name, where
60 # the em dash survives as the maiden value because Jane Smith
61 # carries the content -- ..._content_free_in_a_name beside it.
62 #
63 # The TOKENS go, not the diagnostics: this drops the name, and
64 # "was the input malformed?" is the one question still worth
65 # answering about it -- most of all here, where there is no parse
66 # left to infer it from. parse('(') keeps its unbalanced-delimiter
67 # report, pointing at no token because no token survived.
68 contentless = not any(
69 c.isalnum() for t in final.values() for c in t.text)
70 if contentless:
71 final = {}
72 ambiguities = []
73 for pending in state.ambiguities:
74 materialized = tuple(final[i] for i in pending.indices
75 if i in final)
76 if pending.indices and not materialized and not contentless:
77 # every referent was dropped: the ambiguity describes
78 # nothing that survives assembly. Born-empty ambiguities
79 # (unbalanced delimiters) are token-independent and kept.
80 # Not so when the whole name was emptied just above -- the
81 # referents did not lose a contest, they were discarded
82 # wholesale, and the report still describes the input.
83 continue
84 ambiguities.append(
85 Ambiguity(pending.kind, pending.detail, materialized))
86 return ParsedName(original=state.original,
87 tokens=tuple(final.values()),
88 ambiguities=tuple(ambiguities))