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 (
23 Ambiguity, AmbiguityKind, ParsedName, Role, Token,
24)
25
26
27# rules.md#A2: "a name with no name content parses to the empty
28# name — every field empty, false as a boolean — while ambiguities
29# born from its punctuation survive on the empty result"
30# (history: decisions.md#A2)
31def assemble(state: ParseState) -> ParsedName:
32 dropped = set(state.dropped)
33 final: dict[int, Token] = {}
34 for i, t in enumerate(state.tokens):
35 if i in dropped:
36 continue
37 role = t.role if t.role is not None else Role.GIVEN
38 final[i] = Token(t.text, t.span, role, t.tags)
39 # No alphanumeric character among the SURVIVING tokens means no
40 # name: a bare '.' or '- -' is not a person. v1 kept such input
41 # (parse('.') -> first '.'); 2.0 empties it so bool() stays an
42 # honest "did I get a name?" check. isalnum() is Unicode-aware, so
43 # every real name in any script has content and only pure
44 # punctuation/symbols empty out. (Embedded junk in a name with
45 # content -- 'John . Smith' -- is left alone: that parse is truthy,
46 # so bool() is not misled.)
47 #
48 # This read "anywhere" until #329, and the two said the same thing
49 # for as long as nothing could take a content-bearing token away.
50 # Dropping a maiden marker inside a delimited clause can, and
51 # "(née —)" under Policy(maiden_delimiters=...) is the input where
52 # the readings part: the marker is the clause's only alnum token,
53 # so once it goes structural the em dash is all that survives and
54 # the whole parse empties -- where 1.4.0 and pre-#329 both gave
55 # maiden 'née —'. Deliberate, not fallout: a dropped marker is
56 # structural like a delimiter character, and brackets plus a marker
57 # plus a dash name no one, exactly as "(-)" already named no one.
58 # A guard keyed on what ELSE is in the clause would be a different
59 # rule, and would leave maiden holding marker-plus-punctuation.
60 # Pinned by cases.py's maiden_marker_delimited_content_free and,
61 # for the other side of it -- the same clause inside a name, where
62 # the em dash survives as the maiden value because Jane Smith
63 # carries the content -- ..._content_free_in_a_name beside it.
64 #
65 # The TOKENS go, not the diagnostics: this drops the name, and
66 # "was the input malformed?" is the one question still worth
67 # answering about it -- most of all here, where there is no parse
68 # left to infer it from. parse('(') keeps its unbalanced-delimiter
69 # report, pointing at no token because no token survived.
70 contentless = not any(
71 c.isalnum() for t in final.values() for c in t.text)
72 if contentless:
73 final = {}
74 ambiguities = []
75 for pending in state.ambiguities:
76 materialized = tuple(final[i] for i in pending.indices
77 if i in final)
78 if pending.indices and not materialized and not contentless:
79 # every referent was dropped: the ambiguity describes
80 # nothing that survives assembly. Born-empty ambiguities
81 # (unbalanced delimiters) are token-independent and kept.
82 # Not so when the whole name was emptied just above -- the
83 # referents did not lose a contest, they were discarded
84 # wholesale, and the report still describes the input.
85 continue
86 # rules.md#A1: "a report names the reading the parse took, so
87 # a report whose fork the rest of the parse then resolved to
88 # NEITHER branch is withdrawn rather than carried beside a
89 # reading it contradicts". classify's connective-or-initial
90 # fork offers exactly two readings and its detail says which
91 # it took ("it is read as an initial"); a generation and an
92 # honorific are neither. So where the parse goes on to role
93 # that letter SUFFIX or TITLE the report is false on its
94 # face, and 'JOHN QUINCY SMITH I' carried it beside a
95 # suffix-or-name saying the same token reads as a
96 # generational suffix (#397 second review). 'i' is the first
97 # word that is both a marked connective and suffix
98 # vocabulary, so no parse before this cycle could reach the
99 # shape.
100 #
101 # WITHDRAWN HERE and not emitted later, which is the narrow
102 # reading of mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE
103 # rather than an exception to it: "Emit at the site that
104 # takes the branch, not where an ambiguous tag sits" governs
105 # the EMISSION, classify still owns the fork, and what this
106 # drops is a report whose subject the parse went on to read
107 # as something else. Here is the one place that knows --
108 # roles are final and nothing downstream moves them -- and
109 # the loop already withdraws a report whose referents did not
110 # survive, just above.
111 if (pending.kind is AmbiguityKind.CONJUNCTION_OR_INITIAL
112 and any(t.role in (Role.SUFFIX, Role.TITLE)
113 for t in materialized)):
114 continue
115 ambiguities.append(
116 Ambiguity(pending.kind, pending.detail, materialized))
117 return ParsedName(original=state.original,
118 tokens=tuple(final.values()),
119 ambiguities=tuple(ambiguities))