-
Notifications
You must be signed in to change notification settings - Fork 115
Expand file tree
/
Copy path_types.py
More file actions
1069 lines (977 loc) · 52 KB
/
Copy path_types.py
File metadata and controls
1069 lines (977 loc) · 52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Core value types for the 2.0 API.
Layering (enforced by tests/v2/test_layering.py): this module imports
nothing from nameparser at module level -- it is the bottom of the
module-import dependency graph. The rendering delegates import _render
and matches() imports _parser at call time; TYPE_CHECKING-only imports
supply the Lexicon/Parser annotations.
Repr policy (applies to every v2 type's __repr__, across this module and
_lexicon.py/_policy.py/_locale.py): bounded output only. No repr may scale
with vocabulary size -- collections render as counts or deltas, never
contents.
"""
from __future__ import annotations
import dataclasses
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import Enum, StrEnum
from typing import TYPE_CHECKING, NamedTuple, NoReturn, TypeVar
if TYPE_CHECKING:
from nameparser._lexicon import Lexicon
from nameparser._parser import Parser
class Role(StrEnum):
"""The seven fields of a parsed name, one per :class:`Token`.
Declaration order is the canonical field order everywhere
(``as_dict()``, ``comparison_key()``, rendering). A StrEnum, like
:class:`AmbiguityKind`: members ARE their string values, so
``token.role == "given"`` compares directly and
``str(Role.GIVEN) == "given"``. Members order as strings, so
``sorted()`` yields alphabetical order -- iterate ``Role`` itself
for the canonical order."""
# Declaration order IS the canonical field order:
# every listing of the seven fields anywhere derives from this.
#: Pre-nominal titles and honorifics ("Dr.", "Sir", "Capt.").
TITLE = "title"
#: The given (first) name, or its initial.
GIVEN = "given"
#: Names between given and family -- middle names or initials.
MIDDLE = "middle"
#: The family (last) name, including any particles ("de la Vega").
FAMILY = "family"
#: Post-nominal pieces ("III", "Jr.", "PhD").
SUFFIX = "suffix"
#: Delimited nickname content ("Jonathan 'Jack' Kennedy" -> "Jack").
NICKNAME = "nickname"
#: A birth surname, from a marker word ("Jane Smith née Jones" ->
#: "Jones") or a delimiter pair routed via Policy.maiden_delimiters.
MAIDEN = "maiden"
class Span(NamedTuple):
"""Where a :class:`Token` came from: a character range into
:attr:`ParsedName.original` such that ``original[start:end]`` is
the token's source text (``end`` exclusive). A plain two-int
NamedTuple; ``None`` in :attr:`Token.span` marks a synthetic token
with no source position."""
#: First character index (0-based).
start: int
#: One past the last character index.
end: int
def __add__(self, other: object) -> NoReturn: # type: ignore[override]
# Inherited tuple + would concatenate two spans into a 4-tuple.
# There is deliberately NO covering-span operation: grouping is
# index-run based (the anti-#100 invariant: never resolve by
# joining text back together) and never merges spans.
raise TypeError(
"Span does not support +; tuple concatenation is not a "
"covering span"
)
#: The four :attr:`Token.tags` values that are stable API.
#: "particle" marks a word from the particle vocabulary ("de", "van")
#: wherever it lands -- including a given-name "Van" -- so combine it
#: with Role.FAMILY to get particle-vocabulary family words -- but
#: NOT to reproduce `family_particles`, which since #404 also consults
#: UNJOINED_TAG and excludes a particle standing alone in its part
#: ("Anh Do" has a particle-tagged family word and no family
#: particles); "conjunction" a joining word ("and", "y"); "initial" a
#: word READ as an initial -- initial-shaped in a script that HAS
#: initials ("J." or "А.", never "씨.", #320), or a marked
#: single-letter connective in a name written in one case
#: (rules.md#P3, see CONJUNCTION_OR_INITIAL);
#: "joined" a continuation of the token before it -- within one
#: merged piece the tag is role-blind and every view joins the pair
#: with a space ("Ph." + "D."; 'Smith, Ph. D. Smith' gives first_list
#: ['Ph. D.']), and since #436 it also spans the pieces of one SUFFIX
#: entry, the run of post-nominals the writer wrote without a comma
#: ("MD PhD"), which is the half the suffix view alone reads and
#: joins with a space instead of ", ". Every other tag is namespaced
#: ("vocab:...") and is unstable debugging provenance -- never match
#: against those.
#: This prose is the hand-maintained twin of docs/modules.rst's
#: STABLE_TAGS block; nothing pins the two against each other (the
#: test only compares the frozenset), so edit both or neither.
STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"})
#: A name part whose every word is particle vocabulary is a part
#: where none of them is doing a particle's work -- nothing joins them
#: to a name -- so FOUR views read them as ordinary name words: they
#: anchor `family_base`, drop out of `family_particles`, contribute
#: initials (rules.md#R2), and capitalize like any other name word
#: rather than being lowercased as particles (#407).
#: MARKED rather than untagged: `particle` is stable API and says the
#: word IS particle vocabulary wherever it lands, which stays true, and
#: keeping it leaves a later rule free to report the fork this decides.
UNJOINED_TAG = "vocab:unjoined-particle"
#: The one sanctioned view-reorder marker (namespaced = unstable API).
#: Tokens cannot reorder (span order is validated), so a role fold that
#: must render BEFORE the role's original tokens tags them with this;
#: _text_for, _render.initials and the facade lists all prepend the
#: carriers. Single-sourced here so the emitter (_pipeline/_post_rules)
#: and the consumers cannot drift -- initials() was the consumer that
#: did drift, walking written order until #408.
FOLDED_TAG = "vocab:folded-middle"
#: Raw text spliced into a field after the parse, which no parse ever
#: read: `ParsedName.replace()` stamps it, and so does the facade's
#: v1 pickle load, which rebuilds a name from `*_list` strings alone.
#: A view that is HANDED a vocabulary falls back to it for a token
#: carrying this -- there is no decision to honor -- and reads every
#: other token by its tags (mechanisms.md#RENDER-HONORS-THE-PARSE).
#: `capitalized(lexicon=...)` is the one such view; `initials()` takes
#: no lexicon and so honors tags alone, marked or not.
#: The absence of a SPAN is not that signal and was tried as one: a
#: span-less token is SYNTHETIC, which `Parser.revise()` also builds,
#: from a full sub-parse whose tags it deliberately keeps. Keying the
#: fallback on the span overrode exactly those tags. Absence of TAGS is
#: not the signal either -- an ordinary parsed name word carries none.
UNCLASSIFIED_TAG = "vocab:unclassified"
#: The one-element tag set its two producers stamp, built once.
_UNCLASSIFIED = frozenset({UNCLASSIFIED_TAG})
_E = TypeVar("_E", bound=Enum)
def _coerce_enum(value: object, enum_cls: type[_E], noun: str, plural: str) -> _E:
"""Coerce value to enum_cls, or raise the enriched ValueError listing
every valid member (enum lookups stay ValueError for any input --
stdlib EnumType precedent, see AGENTS.md's taxonomy rule)."""
if isinstance(value, enum_cls):
return value
try:
return enum_cls(value)
except ValueError:
valid = ", ".join(str(m.value) for m in enum_cls)
raise ValueError(
f"unknown {noun} {value!r}; valid {plural}: {valid}"
) from None
# Pickle support shared by the frozen slots dataclasses: fail at the
# LOAD site when a pickle's field layout does not match this version of
# the class (version skew) -- silently loading would defer the failure
# to a distant attribute read. Values are deliberately NOT re-validated:
# pickle is not a security boundary (arbitrary pickles can execute code
# anyway), and canonical state only comes from a validated instance.
# These are ASSIGNED IN EACH CLASS BODY (not inherited from a mixin):
# @dataclass(slots=True) regenerates the class and installs its own
# pickle methods unless __getstate__/__setstate__ are in the class's
# own __dict__. Lexicon duplicates this logic by design (its slots also
# carry a rebuilt mappingproxy) -- layering keeps _lexicon import-free
# of _types.
def _guarded_getstate(self: object) -> dict[str, object]:
fields = dataclasses.fields(self) # type: ignore[arg-type]
return {f.name: getattr(self, f.name) for f in fields}
def _guarded_setstate(self: object, state: dict[str, object]) -> None:
fields = dataclasses.fields(self) # type: ignore[arg-type]
expected = {f.name for f in fields}
if set(state) != expected:
missing = ", ".join(sorted(expected - set(state))) or "none"
unexpected = ", ".join(sorted(set(state) - expected)) or "none"
raise ValueError(
f"incompatible {type(self).__name__} pickle: missing "
f"fields: {missing}; unexpected fields: {unexpected}"
)
for name, value in state.items():
object.__setattr__(self, name, value)
@dataclass(frozen=True, slots=True)
class Token:
"""One classified word of a parsed name: its text, where it came
from, which field it belongs to, and how it was classified. Read
tokens off :attr:`ParsedName.tokens` or
:meth:`ParsedName.tokens_for`; you only construct one directly
when hand-building a :class:`ParsedName`."""
#: The word exactly as written in the input (never empty).
text: str
#: Position in ParsedName.original; None marks a synthetic token
#: (e.g. introduced by replace()) with no source position.
span: Span | None
#: The field this token belongs to.
role: Role
#: Classification labels. Exactly the four members of
#: :data:`~nameparser.STABLE_TAGS` ("particle", "conjunction",
#: "initial", "joined") are API; namespaced tags like "vocab:..."
#: are unstable debugging provenance -- never match against them.
tags: frozenset[str] = frozenset()
# in the class body so @dataclass(slots=True) keeps them
__getstate__ = _guarded_getstate
__setstate__ = _guarded_setstate
def __post_init__(self) -> None:
if not isinstance(self.text, str):
raise TypeError(
f"Token.text must be a str, got {self.text!r}"
)
if not self.text:
raise ValueError("Token.text must be a non-empty string")
object.__setattr__(
self, "role", _coerce_enum(self.role, Role, "Role", "roles"))
if self.span is not None:
if not (
isinstance(self.span, tuple)
and len(self.span) == 2
# bool is an int subclass: (False, True) is a comparison
# result leaking into a coordinate slot, not a span
and all(isinstance(v, int) and not isinstance(v, bool)
for v in self.span)
):
raise TypeError(
f"invalid span {self.span!r}: expected a (start, end) "
"pair of ints or None"
)
start, end = self.span
if start < 0 or end < start:
raise ValueError(
f"invalid span ({start}, {end}): need 0 <= start <= end"
)
object.__setattr__(self, "span", Span(start, end))
# The same guards _normset applies to Lexicon vocabulary: a bare
# string would become its character set, a mapping would silently
# contribute only its keys.
if isinstance(self.tags, str):
raise TypeError(
"Token.tags must be an iterable of strings, "
"not a bare string"
)
if isinstance(self.tags, Mapping):
raise TypeError(
"Token.tags must be an iterable of strings, not a mapping"
)
tags = frozenset(self.tags)
for tag in tags:
if not isinstance(tag, str):
raise TypeError(
f"Token.tags must contain only strings, got {tag!r}"
)
object.__setattr__(self, "tags", tags)
def __repr__(self) -> str:
# Bounded output: a single token's text/span/role/tags, never
# scales with vocabulary size (design rule -- see module docstring).
where = (f"@{self.span.start}:{self.span.end}"
if self.span is not None else "@synthetic")
tags = f" {{{', '.join(sorted(self.tags))}}}" if self.tags else ""
return f"Token({self.text!r} {where} {self.role.name}{tags})"
@dataclass(frozen=True, slots=True)
class Segmentation:
"""A segmenter's answer for one unspaced token: the interior offsets
to split at (each offset begins a new piece, so
``Segmentation((2,))`` cuts a three-character token into
``token[:2]`` and ``token[2:]``; strictly ascending, each >= 1 -- an
index protocol, so a segmenter physically cannot invent, drop, or
rewrite characters) and an optional confidence in [0, 1].
``Segmentation(())`` means "confidently one token" -- distinct from
returning None, which DECLINES ("I don't know"). The upper bound
(< len(token)) is the half this class cannot check, never having
seen the text; the consuming stage checks it and RAISES
``ValueError`` on a violation, the same call it makes on an answer
of the wrong type -- both are protocol bugs in the segmenter, not
facts about the name."""
#: Interior character offsets to split at, ascending.
splits: tuple[int, ...]
#: How sure the segmenter is, or None for "no opinion".
confidence: float | None = None
# in the class body so @dataclass(slots=True) keeps them
__getstate__ = _guarded_getstate
__setstate__ = _guarded_setstate
def __post_init__(self) -> None:
# Both guards Token.tags carries, for the same two reasons and
# a third of this field's own: a bare string is iterable, so
# Segmentation("") would sail through as "confidently one
# token" -- an opinion nobody stated -- and Segmentation("23")
# would fail one character deep naming '2' rather than the
# argument. A mapping would silently contribute only its keys,
# and a bare int would surface as an uncurated "not iterable"
# from the tuple() below.
if isinstance(self.splits, str):
raise TypeError(
"Segmentation.splits must be an iterable of integers, "
"not a bare string"
)
if isinstance(self.splits, Mapping):
raise TypeError(
"Segmentation.splits must be an iterable of integers, "
"not a mapping"
)
try:
iter(self.splits)
except TypeError:
raise TypeError(
f"Segmentation.splits must be an iterable of integers, "
f"got {self.splits!r}"
) from None
# OUTSIDE the try on purpose: iter() cannot run a generator's
# body, but tuple() can, and a TypeError raised in there is the
# caller's own bug -- relabeling it "must be an iterable" would
# send the reader to the wrong place entirely.
splits = tuple(self.splits)
for offset in splits:
# bool is an int subclass: True as an offset is a comparison
# result leaking into an index slot, not a split point
if isinstance(offset, bool) or not isinstance(offset, int):
raise TypeError(
f"Segmentation.splits must be integers, got {offset!r}")
if offset < 1:
raise ValueError(
f"Segmentation.splits must be interior offsets "
f"(each >= 1), got {offset}")
if any(b <= a for a, b in zip(splits, splits[1:])):
raise ValueError(
f"Segmentation.splits must be strictly ascending, "
f"got {splits!r}")
object.__setattr__(self, "splits", splits)
conf = self.confidence
if conf is not None:
if isinstance(conf, bool) or not isinstance(conf, (int, float)):
raise TypeError(
f"Segmentation.confidence must be a float or None, "
f"got {conf!r}")
# stored as given, not coerced to float: an int 1 is a valid
# confidence and the range check is what the callers rely on
if not 0.0 <= conf <= 1.0:
raise ValueError(
f"Segmentation.confidence must be within [0, 1], "
f"got {conf!r}")
#: The segmenter hook's shape: token text in, :class:`Segmentation` out,
#: None to decline. Plug one in via ``Parser(segmenter=...)``.
Segmenter = Callable[[str], Segmentation | None]
class AmbiguityKind(StrEnum):
"""The stable vocabulary of :class:`Ambiguity` kinds. A StrEnum:
members ARE their string values, so ``kind == "particle-or-given"``
compares directly. New kinds may be added in minor releases;
existing values never change meaning.
A kind names a FORK THE PARSE HAD TO CALL, not a word that could be
read two ways, except where a member says otherwise: the same token
elsewhere in a name may present no choice at all and is then
reported by nothing. COMMA_STRUCTURE and UNBALANCED_DELIMITER are
the two that say otherwise -- each reports a shape the parse could
not recognize rather than a fork it chose between, and each says so
on its own member below. Reporting is also
partial -- a kind listed here is not necessarily emitted everywhere
its fork occurs (the comma's structure decision reports no
reading by design, except where it decides a member of the
ambiguous credential class, where since 2.4 that decision is
reported either way -- #289; an attachment decided AFTER a
family comma is a separate fork and has reported on its own
since 2.3, e.g. "Berg, Jan vd"; the two structural kinds above
were never covered by either silence), and coverage grows over
releases. A non-empty tuple is
a signal to act on; an empty one is not a guarantee of
certainty."""
#: Reserved: the name's field order itself is uncertain (e.g. a
#: two-word name under a non-default name_order). Not yet emitted;
#: planned for 2.x. A lone name word is GIVEN_OR_FAMILY's, not
#: this.
ORDER = "order"
#: Delimited content is an ambiguous suffix acronym, so it reads
#: plausibly as either a post-nominal or a nickname -- "JEFFREY
#: (JD) BRICKEN" keeps the nickname reading, where the
#: unambiguous "(MBA)" escapes to suffix on vocabulary alone.
SUFFIX_OR_NICKNAME = "suffix-or-nickname"
#: A trailing word reads plausibly as either a post-nominal or an
#: ordinary name part. Covers an ambiguous acronym written without
#: periods ("John Smith MA" takes MA as a credential because a
#: family name remains; "Jack MA" keeps it as the name because none
#: would) and a trailing roman numeral, which is a suffix where any
#: other single letter would be a name ("John Smith V" vs "John
#: Smith B"). It also covers a family-comma listing, where a
#: trailing abbreviation that is both a post-nominal and a surname
#: particle joins the surname the comma already named: "Berg, Jan
#: vd" takes ``vd`` for *van der* and declines the decoration.
#: Which name part was declined depends on position and
#: ``name_order``, so ``detail`` names it rather than the kind.
#: The same doubt covers an input that is nothing BUT post-nominal
#: vocabulary ("Rinpoche", "QC MP"): with no name word beside it
#: the first post-nominal is read as the name, because something
#: has to be one, and only that word reports.
#: WHERE it is emitted is narrower than where the doubt exists,
#: and this is the boundary rather than an omission to be read
#: past. The emitters cover the trailing slot of a name, the FIRST
#: PIECE after a family comma -- that piece and no further -- the
#: trailing slot of that listing's GIVEN part, the trailing slot
#: of a maiden marker's clause, and the extra segments beyond it.
#: Since 2.4 the given part's trailing slot reports whichever way
#: it read the word: "Doe, John MA" reads suffix ``MA`` and says
#: so, "Doe, John Ma" keeps middle ``Ma`` and says so too. Not in
#: EVERY direction, though: where the member is also a particle
#: and the particle rule keeps it, that rule reports at its own
#: site and this kind stays out of the way -- "Doe, John do" gives
#: family ``do Doe`` and one ``PARTICLE_OR_GIVEN``, never two
#: reports of one word.
#: Since 2.4 a maiden marker's clause reports at ITS trailing slot
#: too, in both directions: "Doe, Jane nee Smith MA" gives maiden
#: ``Smith`` with suffix ``MA`` and says so, "Doe, Jane nee Smith
#: Ma" keeps maiden ``Smith Ma`` and says so too, and a member the
#: clause keeps because it is the only word after the marker
#: ("Jane Doe nee MA") reports as well. Where no trailing rule
#: reads the clause's tail, nothing was decided and nothing
#: reports: a clause in the FAMILY segment of a comma listing
#: ("Smith nee Jones MA, Jane" keeps maiden ``Jones MA``) and one
#: past a second comma ("Smith, John, Jr nee Jones MA" keeps
#: maiden ``Jones MA``) are both silent.
#: FOUR positions stay silent, and all four are boundaries rather
#: than omissions. The NO-READER clause just named is the first of
#: them, and the three that follow are about the member's own
#: surroundings. Second: a member with something BEHIND it that
#: the trailing reading does not take was never a fork -- "Doe,
#: John MA Smith" reads middle ``MA Smith``, the ordinary reading,
#: and nothing consulted the class, and inside a clause the same
#: holds of a name word ("Jane Doe nee MA Smith" keeps maiden
#: ``MA Smith``) and of a trailing TITLE, which breaks the
#: clause's peel before it can reach the member at all ("Jane Doe
#: nee Smith MA Prof." keeps maiden ``Smith MA Prof.``, where
#: "Jane Doe nee Smith Prof. MA" reads suffix ``MA`` and reports).
#: Third -- pre-existing, and untouched by 2.4 -- a member with no
#: name word IN FRONT of it is not at this
#: slot either, because the slot is the end of a given part and
#: there is none: a TITLE or a POST-NOMINAL took that position --
#: either one leaves the segment with no name word for the slot to
#: be the end of. "Doe, Dr. MA" gives
#: suffix ``MA`` and "Doe, Mr. MA PhD" suffix ``MA PhD``, the
#: credential-run gate reading those segments whole; "Doe, Dr. Ma"
#: reaches the walk instead and makes ``Ma`` the given name
#: itself, which the walk starts above. The first-piece emitter
#: does not cover for it, reading the piece that stands
#: immediately after the comma and nothing behind that piece:
#: "Doe, MA Smith" reports its ``MA``, and "Doe, Dr. MA Smith" --
#: the same member, one title in front of it -- reads given ``MA``
#: in silence. A maiden CLAUSE does NOT reach this position, and
#: that is a decision rather than an accident: a clause may give
#: a word up only where the word then reads as a post-nominal
#: (rules.md#M2), and here nothing would read it at all, so the
#: clause keeps it and reports instead. "Doe, Dr. nee Smith MA"
#: keeps maiden ``Smith MA`` and says so, as does the
#: post-nominal spelling "Jane Doe, Jr nee Smith MA" -- the take
#: would leave segment 1 as ``Jr MA``, post-nominals only, which
#: the gate reads whole. The same holds of the fourth position
#: below: a clause never hands a word to a join.
#: And fourth -- pre-existing and untouched by 2.4 as well --
#: a JOIN beside the member can
#: take it out of this slot, from either side. Where a chain has
#: swallowed the member into one piece there is no lone member to
#: ask about, and the member may as well HEAD that piece as trail
#: it: "Doe, John van Ma" reads middle ``van Ma`` in silence, and
#: so does "Doe, John DO Ed", where the member is itself the
#: particle the chain runs on and the name word behind it joins
#: the piece -- though "Doe, John DO" alone reads the credential
#: and reports. The particle chain is the commonest joiner but not
#: the only one: the bound-given join takes a member into its pair
#: the same way, so "Berg, abdul MA" reads given ``abdul MA`` in
#: silence -- while "Berg, abdul nee Jones MA" keeps maiden
#: ``Jones MA`` and reports, the clause declining to hand the
#: member to a join that would swallow it. Where a particle the suffix vocabulary
#: does not also claim stands BEHIND it, the given part ends at
#: that particle as this walk reads it, and the attachment that
#: moves the particle to the family runs a stage too late to
#: re-open the question -- so "Doe, John MA do" keeps middle
#: ``MA``, capitals and all, beside family ``do Doe``, reporting
#: only the attachment's own ``PARTICLE_OR_GIVEN``. Neither half
#: is a rule about particles as such, and the caps spellings show
#: it: "Doe, John van MA" reads family ``van Doe``, suffix ``MA``
#: and reports both forks, and "Doe, John MA vd" reads suffix
#: ``MA`` past a ``vd`` the suffix vocabulary claims outright.
#: All four POSITIONS above are silent -- those last two names
#: are the boundary each one stops at, not instances of it.
#: A DELIMITED maiden clause is quiet for a different reason and
#: is not one of the four. Where a recognized marker stands
#: inside a delimited span the whole span is the maiden name,
#: whatever its last word is and whether or not the pair is a
#: configured maiden delimiter: "Jane Doe (nee Smith MA)" and
#: "Jane Doe (nee Smith Ma)" both give maiden ``Smith MA`` /
#: ``Smith Ma`` and report nothing. The writer drew the boundary,
#: so no fork was available to decline -- a settled position
#: rather than a fork left un-asked. The boundary cuts both ways:
#: "Jane Doe (nee Smith) MA" gives suffix ``MA`` and reports, the
#: member standing outside the span.
SUFFIX_OR_NAME = "suffix-or-name"
#: An input the title peel eats down to one last word which is
#: itself title vocabulary still has to name somebody, so that
#: word is read as the name -- "Lord Chancellor" gives family
#: "Chancellor". A convention, not evidence: this is a name parser
#: rather than a title parser, and handed a string whose every
#: remaining word is a title the alternative is a title with no
#: name at all. ``detail`` names that word. The report is made
#: before the rule that moves the word between fields, so the peel
#: shape reports under either order and names no field; the join
#: shape's ``detail`` names the field it was placed in. The peel
#: shape points at one token, the join shape at the whole unit.
#: A LONE title word reports nothing: "Dr." reads as a title
#: standing by itself, the peel left no word to be read as a
#: name, and no fork was taken.
#: One name word that is a JOIN carrying title vocabulary reports
#: this kind too ("John of Prince"): the unit is read as a name,
#: and whether the title word inside it is a title is the fork.
#: Not the title-vs-given-name collision on a word like "Baron",
#: which is a question about the VOCABULARY and is not this kind.
TITLE_OR_NAME = "title-or-name"
#: An ambiguous particle is either a particle or a name in its own
#: right -- "Van Johnson" is the actor's given name, a bare
#: "Van Buren" the presidential surname, and the two-word shape
#: cannot distinguish them. Three
#: shapes report this kind, decided in different stages, and
#: ``detail`` is what tells them apart. A particle left standing
#: alone chained nothing and was assigned a role, which ``detail``
#: names ("read as a given name") -- that role is whatever
#: assignment gave it, so it follows ``name_order`` and any
#: ``script_orders`` entry, which is why the kind cannot name it.
#: A particle that something ahead of it shifted off the front of
#: the name was instead claimed by the prefix chain, and ``detail``
#: says that and names no field at all: grouping runs before roles
#: exist, so that text is the same under every order. Since #367 a
#: plain title is not such a thing -- "Dr. Van Johnson" reads as
#: the untitled "Van Johnson" does and takes the first shape --
#: and what remains is a leading word that is both a title and a
#: particle, so it stays a name piece and the particle behind it is
#: genuinely not leading ("Freiherr von Richthofen").
#: The third shape is at the TAIL rather than the head: a family
#: comma names the surname, and a particle written behind the given
#: name after it -- "Beethoven, Ludwig van", the Dutch and Flemish
#: filing convention -- is attached to that surname, over its
#: reading as an ordinary name word. The comma settles which piece
#: is the family and nothing about this, so the fork is real and
#: ``detail`` names the word it turned on.
PARTICLE_OR_GIVEN = "particle-or-given"
#: A single-letter connective in a name written wholly in ONE case
#: -- all upper or all lower alike -- where the writing therefore
#: says nothing about which reading was meant. The letter is read as
#: an INITIAL and this reports the fork: "jose e maria santos" gives
#: middle "e maria" and "JOSE E MARIA SANTOS" middle "E MARIA",
#: both flagged. Only a letter the vocabulary marks both ways
#: reports -- ``Lexicon.conjunctions_ambiguous``, "e" by default --
#: because a letter outside it is not in doubt: "JUAN GARCIA Y
#: LOPEZ" joins into family "GARCIA Y LOPEZ" and reports nothing,
#: as does the Cyrillic "ХОСЕ И МАРИЯ САНТОС" -- whose join lands
#: in GIVEN "ХОСЕ И МАРИЯ" rather than FAMILY, but reports nothing
#: all the same.
#: Four things never reach the fork. MIXED-case input decides on
#: the writing instead, so "Jose E Maria Santos" reads the capital
#: as an initial and "John e Smith" the lowercase letter as the
#: connective, neither reporting. A CASELESS letter has no case to
#: read, so Arabic "محمد و علي" keeps its connective silently. A
#: MULTI-letter connective ("and", "та", "και") or a symbol ("&") is
#: no initial's shape at any casing. And a letter inside a maiden or
#: delimited clause never reaches the fork either (the own-words
#: doctrine, rules.md#P3): appending " née Jones" or a nickname
#: changes no report, because the clause's words were never
#: eligible for it.
#: ``detail`` names the token, the kind naming neither the field nor
#: the letter: which field the reading lands in follows the name's
#: shape and its ``name_order``, the PARTICLE_OR_GIVEN precedent.
CONJUNCTION_OR_INITIAL = "conjunction-or-initial"
#: A name of one name word that nothing else decided had to be read
#: as one field or the other, and both readings fit it equally well
#: -- "Andrew", "Smith". The convention picks the given name under
#: the default order and the family name under a declared
#: family-first one, the same way every time, so ``detail`` names
#: the field the convention chose rather than the kind naming it:
#: the same reason PARTICLE_OR_GIVEN cannot. A name something DID
#: decide reports nothing: a title, a maiden name, a family comma
#: that names a family, a script whose own convention settles the
#: order, or the vocabulary claiming the word (a particle, a bound
#: given name, an initial's shape) each settle the reading, and a
#: settled reading is not a fork. A nickname or a suffix standing
#: BESIDE the one name word does not settle it -- "'Smitty' Jones
#: Jr." and "Smith Jr." both report.
GIVEN_OR_FAMILY = "given-or-family"
#: A nickname/maiden delimiter opened without closing (or closed
#: without opening); the text was kept as literal name content, so
#: the tokens are the one the stray character ended up inside.
#: NOT a fork the parse called: it reports a shape the parse could
#: not recognize, which is the carve-out this enum's own docstring
#: names for this member and for COMMA_STRUCTURE. That docstring
#: said "each says so on its own member below" while only
#: COMMA_STRUCTURE's did; this sentence is the other half
#: (2026-09-18).
#: Two cases leave that tuple empty: a character that lands in no
#: token at all (inside a masked region), and an input with no
#: alphanumeric content anywhere, which parses to an empty name --
#: the report survives because "was this malformed?" is the only
#: question left, but there is no token for it to point at.
#: ``parse("(")`` is the second case, not an exotic one.
UNBALANCED_DELIMITER = "unbalanced-delimiter"
#: More comma-separated segments than any recognized name shape;
#: the parse is best-effort over the extra segments.
COMMA_STRUCTURE = "comma-structure"
#: A division of an unspaced CJK token that the parse had to
#: choose, from either of the two things that can divide one.
#: A VOCABULARY fork: more than one surname-supported split
#: existed ("夏侯惇" was taken as 夏侯 + 惇, while 夏 + 侯惇 also
#: matched), longest-match picked, and ``detail`` names both
#: readings (#271). Or a SEGMENTER answer scoring under the
#: stage's confidence floor: only one reading was offered, but the
#: score says it was a statistical guess rather than a stated
#: certainty, and ``detail`` names the pieces and the score
#: (#272). Either way it points at ALL the tokens the division
#: produced -- two for a vocabulary split, n+1 for a segmenter
#: answer cutting n times.
SEGMENTATION = "segmentation"
@dataclass(frozen=True, slots=True)
class Ambiguity:
"""A call the parser made that could legitimately have gone the
other way, surfaced on :attr:`ParsedName.ambiguities` instead of
silently guessed away. The parse still commits to one reading --
an Ambiguity is a flag for review, not an error."""
#: Which known ambiguity shape this is (stable API values).
kind: AmbiguityKind
#: Human-readable specifics of this occurrence (wording unstable).
detail: str
#: The tokens involved -- always a value-equal subset of the owning
#: ParsedName's tokens (checked with ==, not identity: two distinct
#: Token instances with identical text/span/role/tags satisfy this);
#: may be empty (e.g. unbalanced-delimiter).
tokens: tuple[Token, ...]
# in the class body so @dataclass(slots=True) keeps them
__getstate__ = _guarded_getstate
__setstate__ = _guarded_setstate
def __post_init__(self) -> None:
object.__setattr__(
self, "kind",
_coerce_enum(self.kind, AmbiguityKind, "AmbiguityKind", "kinds"))
if not isinstance(self.detail, str):
raise TypeError(
f"Ambiguity.detail must be a str, got {self.detail!r}"
)
if not self.detail:
raise ValueError("Ambiguity.detail must be a non-empty string")
toks = tuple(self.tokens)
for tok in toks:
if not isinstance(tok, Token):
raise TypeError(
f"Ambiguity.tokens must contain only Token instances, "
f"got {tok!r}"
)
object.__setattr__(self, "tokens", toks)
def __repr__(self) -> str:
texts = "/".join(repr(t.text) for t in self.tokens)
return f"Ambiguity({self.kind.value!r}: {texts})"
def _validated_field_strings(fields: dict[str, str]) -> dict[Role, str]:
"""Shared by ParsedName.replace and Parser.revise: validate a
**fields mapping of role names to replacement strings and key it
by Role. TypeErrors match replace()'s historical wording."""
by_value = {role.value: role for role in Role}
for key, value in fields.items():
if key not in by_value:
raise TypeError(
f"unknown field {key!r}; expected one of "
f"{', '.join(by_value)}"
)
if not isinstance(value, str):
raise TypeError(
f"field {key!r} must be a str, got {value!r}"
)
return {by_value[k]: v for k, v in fields.items()}
def _remarked(tokens: list[Token]) -> tuple[Token, ...]:
"""UNJOINED_TAG recomputed over an edited token list.
The mark says a particle stands ALONE in its part, which is a fact
about the part rather than the word, so an edit that re-roles
tokens invalidates it in both directions: replace()/revise() splice
a sub-parse's tokens into one field, and a particle marked alone
there can land beside a name word (stale mark) while an unmarked
one can end up alone (missing mark). Parser.revise strips
FOLDED_TAG for the same reason; this one is RECOMPUTED rather than
stripped, because absent is only correct for half the cases.
"""
out = list(tokens)
for role in (Role.GIVEN, Role.MIDDLE, Role.FAMILY):
part = [i for i, t in enumerate(out) if t.role is role]
alone = bool(part) and all("particle" in out[i].tags for i in part)
for i in part:
tags = out[i].tags
if alone and UNJOINED_TAG not in tags:
out[i] = dataclasses.replace(out[i], tags=tags | {UNJOINED_TAG})
elif not alone and UNJOINED_TAG in tags:
out[i] = dataclasses.replace(out[i],
tags=tags - {UNJOINED_TAG})
return tuple(out)
@dataclass(frozen=True, slots=True)
class ParsedName:
"""The immutable result of parsing one name string. Read the seven
fields as strings (``.given``, ``.family``, ...); inspect structure
through :attr:`tokens` / :meth:`tokens_for`; correct a parse with
:meth:`replace` (returns a new value; Parser.revise is the
tag-preserving form); produce output with
:meth:`render`, :meth:`initials`, :meth:`capitalized`, or ``str()``.
Constructor-enforced invariants: spans ascending, non-overlapping,
in bounds of `original`; every Ambiguity's tokens are a value-equal
subset of `tokens` (see Ambiguity.tokens). Provenance semantics
(text == original[span] for parser-produced names) are documented,
not enforced -- transforms like replace() legitimately break them.
"""
#: The input string exactly as passed to parse().
original: str
#: Every classified token, in document order.
tokens: tuple[Token, ...]
#: Judgment calls that could have gone the other way; empty for
#: most names (see Ambiguity).
ambiguities: tuple[Ambiguity, ...] = ()
# in the class body so @dataclass(slots=True) keeps them
__getstate__ = _guarded_getstate
__setstate__ = _guarded_setstate
def __post_init__(self) -> None:
if not isinstance(self.original, str):
raise TypeError(
f"ParsedName.original must be a str, got {self.original!r}"
)
object.__setattr__(self, "tokens", tuple(self.tokens))
object.__setattr__(self, "ambiguities", tuple(self.ambiguities))
for tok in self.tokens:
if not isinstance(tok, Token):
raise TypeError(
f"ParsedName.tokens must contain only Token instances, "
f"got {tok!r}"
)
for amb in self.ambiguities:
if not isinstance(amb, Ambiguity):
raise TypeError(
f"ParsedName.ambiguities must contain only Ambiguity "
f"instances, got {amb!r}"
)
prev_end = 0
for tok in self.tokens:
if tok.span is None:
continue
if tok.span.end > len(self.original):
raise ValueError(
f"token {tok.text!r} span {tuple(tok.span)} is out of "
f"bounds for original of length {len(self.original)}"
)
if tok.span.start < prev_end:
raise ValueError(
f"token spans must be ascending and non-overlapping; "
f"token {tok.text!r} at {tuple(tok.span)} begins before "
f"offset {prev_end}"
)
prev_end = tok.span.end
# Hash once rather than rescanning the tuple per referenced
# token: a name can carry an ambiguity per token (a string of
# stray delimiters does), and the linear form made construction
# quadratic in their product. Set membership uses the same value
# equality the tuple scan did -- Token is frozen and hashable.
if self.ambiguities:
known = set(self.tokens)
for amb in self.ambiguities:
for tok in amb.tokens:
# membership is by Token's value equality, not
# identity: this only guarantees a value-equal token
# exists in self.tokens, not that `tok` IS one of
# those objects.
if tok not in known:
raise ValueError(
f"Ambiguity token {tok.text!r} is not a "
f"subset of this ParsedName's tokens"
)
def __bool__(self) -> bool:
return bool(self.tokens)
def __str__(self) -> str:
return self.render()
def __repr__(self) -> str:
# 4-space indent, matching HumanName's repr (v1 style)
lines = []
for role in Role:
text = self._text_for(role)
if text:
lines.append(f" {role.value}: {text!r}")
if self.ambiguities:
items = [
f"{a.kind.value}: {'/'.join(t.text for t in a.tokens)}"
if a.tokens else a.kind.value
for a in self.ambiguities
]
lines.append(f" ambiguities: {items!r}")
body = "\n".join(lines)
return f"<ParsedName: [\n{body}\n]>" if lines else "<ParsedName: []>"
# -- string views (canonical order = Role declaration order) --------
def _text_for(self, *roles: Role, tag: str | None = None,
without_tag: str | None = None,
unless_tag: str | None = None) -> str:
suffix_join = roles == (Role.SUFFIX,)
parts: list[str] = []
folded: list[str] = []
for tok in self.tokens:
if tok.role not in roles:
continue
# A token carrying `unless_tag` is read as though it did
# not carry `tag`/`without_tag` at all -- so it is EXCLUDED
# by a `tag=` filter and INCLUDED by a `without_tag=` one,
# which is how an unjoined particle anchors the base and
# leaves the particles view.
waived = unless_tag is not None and unless_tag in tok.tags
if tag is not None and (tag not in tok.tags or waived):
continue
if without_tag is not None and without_tag in tok.tags \
and not waived:
continue
# "joined" (stable tag) marks a continuation of the previous
# token ("Ph." + "D."): attach with a space so the suffix
# view's ", " join does not split one credential in two
if suffix_join and "joined" in tok.tags and parts:
parts[-1] += " " + tok.text
elif FOLDED_TAG in tok.tags:
# middle_as_family fold: v1 PREPENDED middle_list to
# last_list; spans cannot reorder, so the view does
folded.append(tok.text)
else:
parts.append(tok.text)
return (", " if suffix_join else " ").join(folded + parts)
@property
def title(self) -> str:
return self._text_for(Role.TITLE)
@property
def given(self) -> str:
return self._text_for(Role.GIVEN)
@property
def middle(self) -> str:
return self._text_for(Role.MIDDLE)
@property
# rules.md#R1: "every field is a view computed from the parsed
# words at read time, joining its words in written order — except
# folded family words" (O3's fold and P6's tussenvoegsel, which
# render before the rest of the family)
def family(self) -> str:
return self._text_for(Role.FAMILY)
@property
def suffix(self) -> str:
return self._text_for(Role.SUFFIX)
@property
def nickname(self) -> str:
return self._text_for(Role.NICKNAME)
@property
def maiden(self) -> str:
return self._text_for(Role.MAIDEN)
# -- derived views (filters over roles + STABLE tags only) ----------
@property
# rules.md#R2: "the family name splits into further views: the
# base (the family without its leading particles) and the
# particles themselves"
def family_particles(self) -> str:
return self._text_for(Role.FAMILY, tag="particle",
unless_tag=UNJOINED_TAG)
@property
def family_base(self) -> str:
return self._text_for(Role.FAMILY, without_tag="particle",
unless_tag=UNJOINED_TAG)
@property
def surnames(self) -> str:
return self._text_for(Role.MIDDLE, Role.FAMILY)
@property
def given_names(self) -> str:
return self._text_for(Role.GIVEN, Role.MIDDLE)
# -- structured access ----------------------------------------------
def tokens_for(self, role: Role | str) -> tuple[Token, ...]:
"""The tokens of one field, in document order. Takes a Role
member or its string value; anything else raises ValueError
naming the valid roles."""
role = _coerce_enum(role, Role, "Role", "roles")
return tuple(t for t in self.tokens if t.role is role)
def as_dict(self, *, include_empty: bool = True) -> dict[str, str]:
# _text_for handles the suffix ", "-join (single-role SUFFIX call)
d = {role.value: self._text_for(role) for role in Role}
if not include_empty:
d = {k: v for k, v in d.items() if v}
return d
# -- editing ----------------------------------------------------------
def replace(self, **fields: str) -> ParsedName:
"""Return a new ParsedName with the named fields re-tokenized as
synthetic tokens (span=None). Whitespace-splits each value; an
empty value clears the field. original is unchanged (provenance).
Ambiguities referencing replaced tokens are dropped.
Replacement tokens carry no STABLE tag, so tag-driven views
degrade: family_particles empties, particles regain their
initials, and a multi-word suffix is comma-joined.
Parser.revise() is the tag-preserving alternative.
They do carry UNCLASSIFIED_TAG, which says the text was never
read rather than that it was read and found plain: a view
holding a vocabulary falls back to it for these and to the tags
for everything else.
"""
replaced = _validated_field_strings(fields)
synthetic = {
role: tuple(Token(word, None, role, _UNCLASSIFIED)
for word in value.split())
for role, value in replaced.items()
}
return self._with_field_tokens(synthetic)
def _with_field_tokens(
self, replaced: Mapping[Role, tuple[Token, ...]],
) -> ParsedName:
"""Shared tail of replace()/Parser.revise(): splice each role's
replacement tokens in at the role's first position (appended in
canonical order when the role had no tokens); drop ambiguities
whose referents were replaced."""
# Private contract, made self-enforcing: a token filed under a
# key that is not its own role is the one way this shared tail
# could build a semantically wrong ParsedName (the splice keys
# on the mapping, the views key on the token).
for role, toks in replaced.items():
for tok in toks:
if tok.role is not role:
raise ValueError(
f"replacement token {tok.text!r} has role "
f"{tok.role.value}, not {role.value}"
)
new_tokens: list[Token] = []
emitted: set[Role] = set()
for tok in self.tokens:
if tok.role in replaced:
if tok.role not in emitted:
new_tokens.extend(replaced[tok.role])
emitted.add(tok.role)
continue
new_tokens.append(tok)
for role in Role:
if role in replaced and role not in emitted:
new_tokens.extend(replaced[role])
kept = tuple(
amb for amb in self.ambiguities
if all(t in new_tokens for t in amb.tokens)
)
return ParsedName(self.original, _remarked(new_tokens), kept)