nameparser 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {nameparser-2.2.0/nameparser.egg-info → nameparser-2.3.0}/PKG-INFO +1 -1
  2. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_facade.py +31 -12
  3. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_lexicon.py +156 -36
  4. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_parser.py +64 -15
  5. nameparser-2.3.0/nameparser/_pipeline/_assign.py +718 -0
  6. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_classify.py +5 -3
  7. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_group.py +97 -108
  8. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_pieces.py +202 -12
  9. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_post_rules.py +237 -28
  10. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_script_segment.py +103 -32
  11. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_state.py +10 -0
  12. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_vocab.py +55 -16
  13. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_policy.py +4 -1
  14. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_types.py +50 -5
  15. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_version.py +2 -2
  16. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/_invariants.py +6 -5
  17. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/suffixes.py +100 -36
  18. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/titles.py +132 -6
  19. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/util.py +3 -2
  20. {nameparser-2.2.0 → nameparser-2.3.0/nameparser.egg-info}/PKG-INFO +1 -1
  21. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/SOURCES.txt +1 -0
  22. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_capitalization.py +9 -3
  23. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_conjunctions.py +1 -2
  24. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_initials.py +36 -0
  25. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_suffixes.py +68 -17
  26. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_titles.py +26 -7
  27. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/_differential_fixtures.py +12 -1
  28. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/cases.py +2081 -153
  29. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/conftest.py +3 -1
  30. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_assign.py +167 -5
  31. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_group.py +19 -13
  32. nameparser-2.3.0/tests/v2/pipeline/test_pieces.py +252 -0
  33. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_post_rules.py +319 -12
  34. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_script_segment.py +124 -3
  35. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_state.py +6 -3
  36. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_vocab.py +58 -0
  37. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/rules_doc.py +48 -0
  38. nameparser-2.3.0/tests/v2/test_cases.py +235 -0
  39. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_contracts.py +2 -0
  40. nameparser-2.3.0/tests/v2/test_differential.py +4792 -0
  41. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_facade.py +17 -10
  42. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_facade_cases.py +18 -0
  43. nameparser-2.3.0/tests/v2/test_ledger_guards.py +5077 -0
  44. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_lexicon.py +121 -0
  45. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_locales.py +169 -2
  46. nameparser-2.3.0/tests/v2/test_order_correspondence.py +206 -0
  47. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_parser.py +217 -21
  48. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_render.py +19 -0
  49. nameparser-2.3.0/tests/v2/test_rules_doc_grammar.py +172 -0
  50. nameparser-2.2.0/nameparser/_pipeline/_assign.py +0 -429
  51. nameparser-2.2.0/tests/v2/pipeline/test_pieces.py +0 -103
  52. nameparser-2.2.0/tests/v2/test_cases.py +0 -72
  53. nameparser-2.2.0/tests/v2/test_differential.py +0 -1361
  54. nameparser-2.2.0/tests/v2/test_ledger_guards.py +0 -2452
  55. nameparser-2.2.0/tests/v2/test_rules_doc_grammar.py +0 -95
  56. {nameparser-2.2.0 → nameparser-2.3.0}/AUTHORS +0 -0
  57. {nameparser-2.2.0 → nameparser-2.3.0}/LICENSE +0 -0
  58. {nameparser-2.2.0 → nameparser-2.3.0}/MANIFEST.in +0 -0
  59. {nameparser-2.2.0 → nameparser-2.3.0}/README.rst +0 -0
  60. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/__init__.py +0 -0
  61. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/__main__.py +0 -0
  62. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_config_shim.py +0 -0
  63. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_locale.py +0 -0
  64. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/__init__.py +0 -0
  65. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_assemble.py +0 -0
  66. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_extract.py +0 -0
  67. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_segment.py +0 -0
  68. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_tokenize.py +0 -0
  69. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_render.py +0 -0
  70. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/__init__.py +0 -0
  71. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/_deprecated.py +0 -0
  72. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/bound_first_names.py +0 -0
  73. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/bound_given_names.py +0 -0
  74. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/capitalization.py +0 -0
  75. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/conjunctions.py +0 -0
  76. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/maiden_markers.py +0 -0
  77. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/particles.py +0 -0
  78. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/prefixes.py +0 -0
  79. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/regexes.py +0 -0
  80. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/surnames.py +0 -0
  81. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/__init__.py +0 -0
  82. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/ja.py +0 -0
  83. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/ru.py +0 -0
  84. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/tr_az.py +0 -0
  85. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/zh.py +0 -0
  86. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/parser.py +0 -0
  87. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/py.typed +0 -0
  88. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/dependency_links.txt +0 -0
  89. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/requires.txt +0 -0
  90. {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/top_level.txt +0 -0
  91. {nameparser-2.2.0 → nameparser-2.3.0}/pyproject.toml +0 -0
  92. {nameparser-2.2.0 → nameparser-2.3.0}/setup.cfg +0 -0
  93. {nameparser-2.2.0 → nameparser-2.3.0}/tests/__init__.py +0 -0
  94. {nameparser-2.2.0 → nameparser-2.3.0}/tests/base.py +0 -0
  95. {nameparser-2.2.0 → nameparser-2.3.0}/tests/conftest.py +0 -0
  96. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_bound_given_names.py +0 -0
  97. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_brute_force.py +0 -0
  98. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_comma_variants.py +0 -0
  99. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_constants.py +0 -0
  100. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_east_slavic_patronymic_order.py +0 -0
  101. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_first_name.py +0 -0
  102. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_middle_name_as_last.py +0 -0
  103. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_nicknames.py +0 -0
  104. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_output_format.py +0 -0
  105. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_particles.py +0 -0
  106. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_python_api.py +0 -0
  107. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_turkic_patronymic_order.py +0 -0
  108. {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_variations.py +0 -0
  109. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/__init__.py +0 -0
  110. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/__init__.py +0 -0
  111. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_assemble.py +0 -0
  112. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_classify.py +0 -0
  113. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_extract.py +0 -0
  114. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_segment.py +0 -0
  115. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_tokenize.py +0 -0
  116. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_benchmark.py +0 -0
  117. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_cli.py +0 -0
  118. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_config_aliases.py +0 -0
  119. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_config_shim.py +0 -0
  120. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_doc_citations.py +0 -0
  121. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_doc_spellings.py +0 -0
  122. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_layering.py +0 -0
  123. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_locale.py +0 -0
  124. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_policy.py +0 -0
  125. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_properties.py +0 -0
  126. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_regex_sync.py +0 -0
  127. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_reprs.py +0 -0
  128. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_rules_doc.py +0 -0
  129. {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A simple Python module for parsing human names into their individual components.
5
5
  Author-email: Derek Gulbranson <derek73@gmail.com>
6
6
  License: LGPL
@@ -512,16 +512,32 @@ class HumanName:
512
512
  # -- initials -------------------------------------------------------------
513
513
 
514
514
  def _process_initial(self, name_part: str, firstname: bool = False) -> str:
515
- # v1 parser.py:427 verbatim: particles/conjunctions are filtered
516
- # from initials unless the part is a first name. split() rather
517
- # than split(" "): *_list attributes assigned directly bypass
518
- # whitespace normalization, and split(" ") yields empty strings
519
- # for repeated spaces (#232).
515
+ # after v1 parser.py:427, not verbatim: particles and
516
+ # conjunctions are filtered from initials unless the part is a
517
+ # first name. split() rather than split(" ") because split(" ")
518
+ # yields '' between repeated spaces and `part[0]` below would
519
+ # raise IndexError on it (#232). v1 stated the reason as
520
+ # `*_list` attributes bypassing whitespace normalization, which
521
+ # no longer holds -- the `*_list` properties are read-only in
522
+ # 2.x, and assignment through `hn.middle = ...` normalizes --
523
+ # but a doubled space anywhere in a part still reaches here.
520
524
  parts = name_part.split()
521
525
  initials = []
522
526
  for part in parts:
523
- if not (self._is_particle(part)
524
- or self._is_conjunction(part)) or firstname:
527
+ # v1 parser.py:771 (1.4.0): is_conjunction was "in the
528
+ # conjunctions set AND NOT is_an_initial", so a dotted or
529
+ # bare-capital E/Y is the initial it looks like rather
530
+ # than the connective. The 2.0 facade dropped that half
531
+ # and lost the middle initial of 'Scott E. Werner' (#462).
532
+ # _render._INITIAL is v1's `initial` shape, kept in step
533
+ # with the pipeline's copy by tests/v2/test_regex_sync.py;
534
+ # the facade may import _render but not _pipeline
535
+ # (tests/v2/test_layering.py). Scoped here rather than in
536
+ # _is_conjunction: this is the only caller, and a future
537
+ # one should not inherit a decision made for initials.
538
+ conjunction = (self._is_conjunction(part)
539
+ and not _render._INITIAL.fullmatch(part))
540
+ if not (self._is_particle(part) or conjunction) or firstname:
525
541
  initials.append(part[0])
526
542
  if len(initials) > 0:
527
543
  return self.initials_separator.join(initials)
@@ -554,11 +570,14 @@ class HumanName:
554
570
  # -- nothing survived
555
571
  # the filter, so the whole group is particles. The
556
572
  # facade's twin of the core's
557
- # UNJOINED_TAG. NOT pinned against it: both case runners
558
- # compare the seven role fields only, and Case carries no
559
- # initials column, so the one covering test is
560
- # tests/test_initials.py::test_initials_middle_name_all_prefixes. _split_last already applies the same guard to
561
- # the base, which is why last_base was never empty here.
573
+ # UNJOINED_TAG. NOT pinned against it by the case runners,
574
+ # which compare the seven role fields only (Case carries
575
+ # no initials column); the covering test is
576
+ # tests/test_initials.py::test_initials_middle_name_all_prefixes,
577
+ # and since #484 the differential compares initials() on
578
+ # both surfaces for names whose roles agree. _split_last
579
+ # already applies the same guard to the base, which is why
580
+ # last_base was never empty here.
562
581
  return [w[0] for w in words]
563
582
  return (group_initials(self.first_list, True),
564
583
  group_initials(self.middle_list),
@@ -10,8 +10,9 @@ from __future__ import annotations
10
10
  import dataclasses
11
11
  import functools
12
12
  import sys
13
+ import unicodedata
13
14
  import warnings
14
- from collections.abc import Iterable, Mapping
15
+ from collections.abc import Iterable, Mapping, Set
15
16
  from dataclasses import dataclass, field
16
17
  from types import FrameType, MappingProxyType
17
18
  from typing import cast
@@ -83,7 +84,11 @@ _SUBSET_FIELDS = (
83
84
  #: The two are not the same mechanism, and the difference is why the
84
85
  #: exemption is a list rather than a rule. A given_name_titles run is
85
86
  #: identified per word FIRST -- 'lt' and 'col' are each title
86
- #: vocabulary -- and only then joined and looked up. A maiden marker
87
+ #: vocabulary -- and only then looked up, as the whole run's key or as
88
+ #: its LAST word's (_run_addresses_by_given, #489). The whole-run arm
89
+ #: is the one a phrase entry is matched by; the last-word arm is a
90
+ #: single word and cannot reach a phrase, so storage stays what it was
91
+ #: (the two are asked together, not in precedence). A maiden marker
87
92
  #: phrase has no such per-word foothold: 'z' and 'domu' are not markers
88
93
  #: individually, and adding them separately (which this warning used to
89
94
  #: advise) reads 'Maria Kowalska z domu Nowak' as maiden 'domu Nowak'
@@ -94,13 +99,44 @@ _SUBSET_FIELDS = (
94
99
  _PHRASE_FIELDS = ("given_name_titles", "maiden_markers")
95
100
 
96
101
 
102
+ #: Every character the parser reads as a full stop at a WORD'S EDGE
103
+ #: (#322/#323): the ASCII period, the fullwidth FULL STOP U+FF0E, the
104
+ #: IDEOGRAPHIC FULL STOP U+3002 and its halfwidth form U+FF61. One
105
+ #: string, four readers -- the lookup fold below, _vocab's script
106
+ #: classification fold, _script_segment's surname site and honorific
107
+ #: tail match -- so that "which characters are a period" is answered
108
+ #: once; the bundle's fifth site, _pieces' opening-abbreviation shape,
109
+ #: reads no stop set at all but the no-initials repertoire
110
+ #: (_policy._NO_INITIALS) that licenses every reader here. NFKC is
111
+ #: NOT a substitute for listing them: it folds U+FF0E to '.' and
112
+ #: U+FF61 to U+3002, and leaves U+3002 as it is.
113
+ #: Not every period test reads this set, and the ones that do not are
114
+ #: named so the claim is checkable: the interior-period shapes -- the
115
+ #: dotted acronym 'M.A.' and the split 'Ph. D.' (_vocab) -- and FOUR
116
+ #: ASCII-only edge tests on Latin shapes, across THREE modules -- the
117
+ #: initial 'J.' (_vocab), the bracketed credential '(Mgr.)' and the
118
+ #: word-internal apostrophe rule (_extract), and the trailing
119
+ #: middle-initial carve-out 'V.' (_assign) -- stay ASCII. That is a
120
+ #: fact about those TESTS and not about this set's reach: the fold
121
+ #: below is script-agnostic, so a LATIN word wearing a wide stop
122
+ #: reaches vocabulary the ASCII veto would have taken it out of.
123
+ #: 'V。' folds to 'v' and does not end in the ASCII period the
124
+ #: trailing-suffix carve-out tests (_assign, not is_initial), so
125
+ #: it is roman five -- suffix -- where 'V.' stays a middle
126
+ #: initial through that same carve-out; 'Jr。' simply reaches its
127
+ #: entry, and 'Dr。' its title.
128
+ #: decisions.md#cjk-full-stops carries the measurements.
129
+ FULL_STOPS = "..。。"
130
+
131
+
97
132
  def _normalize(word: str) -> str:
98
- """Lowercase, strip whitespace and EDGE periods -- v1's lc()
99
- semantics. Interior periods survive on purpose: 'J.R.' must not
100
- collapse to 'jr' and hit the periodless vocabulary (v1 parity,
101
- pinned live 2026-07-17). Suffix-ACRONYM membership alone uses the
102
- period-free form (see _vocab.suffix_as_written), mirroring v1's
103
- is_suffix, which removed periods only for the acronym test.
133
+ """Lowercase, NFC-compose, strip whitespace and EDGE full stops
134
+ (FULL_STOPS, not the ASCII period alone -- #322). Interior periods
135
+ survive on purpose: 'J.R.' must not collapse to 'jr' and hit the
136
+ periodless vocabulary (v1 parity, pinned live 2026-07-17).
137
+ Suffix-ACRONYM membership alone uses the period-free form (see
138
+ _vocab.suffix_as_written), mirroring v1's is_suffix, which removed
139
+ periods only for the acronym test.
104
140
 
105
141
  lower(), NOT casefold(): casefold's caseless-matching folds mutate
106
142
  the stored vocabulary itself -- 'κος' becomes the misspelling 'κοσ'
@@ -110,39 +146,122 @@ def _normalize(word: str) -> str:
110
146
  match-time lookups, so matching stays symmetric either way; lower()
111
147
  is what v1's lc() used, preserving which cross-spellings match.
112
148
 
113
- Strips to a FIXED POINT. A single strip().strip(".") leaves
114
- periods-around-whitespace half done ('. a .' -> ' a '), so a value
149
+ NFC, since #322, and only for a non-ASCII word (ASCII is already
150
+ NFC, and this runs per token on the parse path --
151
+ decisions.md#parse-cost). NFD hangul decomposes to jamo and missed
152
+ every vocabulary entry; composing at lookup AND at storage keeps
153
+ the two symmetric, and no shipped entry changes under it (measured
154
+ 2026-09-09: fold every string member of every Lexicon.default()
155
+ field through NFC and count the changes; zero of 1735). This is
156
+ the same NFC composition script classification already applies
157
+ (_vocab._normalized_for_script); token text is never rewritten.
158
+
159
+ Strips to a FIXED POINT. A single strip().strip(FULL_STOPS) leaves
160
+ stops-around-whitespace half done ('. a .' -> ' a '), so a value
115
161
  that is re-normalized later -- on unpickle, or by a second add() --
116
162
  would change under its owner. v1 never re-normalized, so this only
117
163
  matters now that storage and match-time share one fold."""
118
164
  word = word.lower()
165
+ if not word.isascii():
166
+ word = unicodedata.normalize("NFC", word)
119
167
  while True:
120
- stripped = word.strip().strip(".")
168
+ stripped = word.strip().strip(FULL_STOPS)
121
169
  if stripped == word:
122
170
  return word
123
171
  word = stripped
124
172
 
125
173
 
126
- def _title_key(words: Iterable[str]) -> str:
127
- """The given_name_titles lookup key for a run of title words.
174
+ def _fold_words(words: Iterable[str]) -> list[str]:
175
+ """The words of a title run, folded for storage and lookup.
128
176
 
129
177
  A multi-word title is matched as one key ('lt col'), so the fold has
130
- to run per word and rejoin -- _normalize on the whole phrase would
131
- leave interior periods. Defined once because it is built at match
132
- time (post_rules for H1, group for the P5 licence -- which must
133
- agree, see #369) and at translation time (the v1 facade's
134
- first_name_titles), and a divergence between them fails silently:
135
- the entry simply stops matching.
136
-
137
- Words that fold away are DROPPED, not joined as empty. Keeping the
138
- gap makes the fold non-idempotent -- 'lt .' would store 'lt ', which
139
- match time can never build (post_rules joins token texts, and a lone
140
- '.' is not a title token), so the entry is inert. Storage re-runs
141
- this fold on unpickle and on every dataclasses.replace, so a value
142
- that changes under a second pass is one Lexicon later rejects as
178
+ to run per word -- _normalize on the whole phrase would leave
179
+ interior periods.
180
+
181
+ Words that fold away are DROPPED, not kept as empty. Keeping the
182
+ gap makes the key non-idempotent -- 'lt .' would store 'lt ', which
183
+ match time can never build: a run CAN carry a lone '.' (the
184
+ conjunction merge puts one there), but the fold drops the empty
185
+ word, so the key is 'lt' and the stored entry is inert. Storage
186
+ re-runs this fold on unpickle and on every dataclasses.replace, so a
187
+ value that changes under a second pass is one Lexicon later rejects as
143
188
  "not written by this version". _normalize converges for the same
144
- reason; so must anything built on top of it."""
145
- return " ".join(filter(None, (_normalize(w) for w in words)))
189
+ reason; so must anything built on top of it.
190
+
191
+ A LIST, so that _run_addresses_by_given's last-word arm can be the
192
+ last word of this fold rather than a re-split of the joined key --
193
+ "the last word of the FOLDED key" is then structural, and the two
194
+ arms read one fold between them.
195
+
196
+ map/filter rather than a comprehension: both are C calls where a
197
+ comprehension is a Python frame on 3.11, and this runs on the parse
198
+ path for every name carrying a title run (decisions.md#parse-cost).
199
+ """
200
+ return list(filter(None, map(_normalize, words)))
201
+
202
+
203
+ def _title_key(words: Iterable[str]) -> str:
204
+ """The given_name_titles lookup key for a run of title words: the
205
+ folded words, space-joined.
206
+
207
+ Defined once because it is built at match time
208
+ (_run_addresses_by_given, which is how post_rules' H1 and group's
209
+ P5 licence both reach it) and at translation time (_config_shim's
210
+ first_name_titles), and a divergence between them fails silently:
211
+ the entry simply stops matching."""
212
+ return " ".join(_fold_words(words))
213
+
214
+
215
+ def _run_addresses_by_given(words: Iterable[str],
216
+ vocabulary: Set[str]) -> bool:
217
+ """Whether a run of title words addresses by the GIVEN name.
218
+
219
+ Two sites ask it and they must agree -- post_rules for H1, group
220
+ for the P5 licence -- because a run read two ways is a rule
221
+ contradicting itself (decisions.md#P5, the 2026-08-22 #369 entry).
222
+ One predicate, so they cannot drift.
223
+
224
+ The WHOLE run's key, or that key's LAST word (#489): a run is
225
+ written as several titles and addresses the way its final one does,
226
+ so 'Her Majesty Queen' addresses by given name because 'queen'
227
+ does. The two arms are asked of one key and neither is the other's
228
+ fallback -- the `or` short-circuits but decides nothing, since for
229
+ a one-word run the key IS its last word. Both arms read one
230
+ _fold_words list, so the last word is the last word of the FOLDED
231
+ key by construction rather than by agreement, and a run token that
232
+ folds away cannot empty that arm: the conjunction merge can put a
233
+ lone '.' in the run ('Sir and . John'), and the fold drops it.
234
+ What the drop leaves as the last word can then be the CONJUNCTION
235
+ -- 'Sir and . John' keys 'sir and' and reads family 'John', where
236
+ 'Sir and Dame John' keys 'sir and dame' and reads given. Harmless
237
+ on the shipped vocabulary, which holds no entry ending in a
238
+ connective, and a caller who stored one would be asking for it
239
+ (measured 2026-09-09).
240
+
241
+ The whole-run arm is what keeps a caller's multi-word phrase entry
242
+ working: 'lt col' is stored as one key and matched as one run. Over
243
+ the SHIPPED vocabulary it is dead -- every shipped entry is a single
244
+ word (asserted in test_lexicon.py), so it can only ever match a
245
+ one-word run, which is a run the last-word arm reads the same way.
246
+
247
+ H2's unlisted abbreviations ride in the run. One can never match as
248
+ the last-word key, being in no vocabulary by definition -- written
249
+ as the inputs that produce those runs, 'Xyz. Sir John' keys 'xyz
250
+ sir', matches on 'sir' and reads given 'John', while 'Sir Xyz.
251
+ John' keys 'sir xyz', matches on neither arm and reads family
252
+ 'John'. It CAN sit inside a whole-run key that matches, because
253
+ given_name_titles is deliberately not validated against titles: a
254
+ caller who stores 'sir xyz' makes 'Sir Xyz. John' read given.
255
+
256
+ The vocabulary is passed in rather than read off a default: a
257
+ caller's own Lexicon is the one that has to be consulted, and this
258
+ module is where Lexicon is defined."""
259
+ folded = _fold_words(words)
260
+ if not folded:
261
+ # every word folded away, so there is no key and no last word:
262
+ # the joined spelling built "" here, which matched nothing
263
+ return False
264
+ return " ".join(folded) in vocabulary or folded[-1] in vocabulary
146
265
 
147
266
 
148
267
  def _reject_buffer(value: object, label: str, plural: str) -> None:
@@ -226,7 +345,7 @@ def _normset(
226
345
  if not n:
227
346
  raise ValueError(
228
347
  f"Lexicon.{field_name} entry {w!r} normalizes to empty "
229
- f"(lowercase + strip periods/whitespace leaves nothing)"
348
+ f"(lowercase + strip full stops/whitespace leaves nothing)"
230
349
  )
231
350
  # Every field outside _PHRASE_FIELDS is matched one word at a
232
351
  # time, so a multi-word entry can never match -- the library
@@ -301,7 +420,7 @@ def _normpairs(
301
420
  if not normalized_key:
302
421
  raise ValueError(
303
422
  f"capitalization_exceptions key {k!r} normalizes to "
304
- f"empty (lowercase + strip periods/whitespace leaves "
423
+ f"empty (lowercase + strip full stops/whitespace leaves "
305
424
  f"nothing)"
306
425
  )
307
426
  # capitalized() looks words up one at a time (the _WORD regex
@@ -323,12 +442,13 @@ class Lexicon:
323
442
  Start from :meth:`default` (the shipped vocabulary) or
324
443
  :meth:`empty`, derive variants with :meth:`add` / :meth:`remove` /
325
444
  ``|`` (union), and pass the result to ``Parser(lexicon=...)``.
326
- Entries are normalized at construction -- lowercased, edge periods
327
- stripped -- so matching is case-insensitive. Vocabulary entries are
328
- single words -- a multi-word entry warns at construction and can
329
- never match. Two fields are exempt, and they differ in HOW they
330
- match: ``given_name_titles`` is looked up as the space-joined run
331
- of words the parse has ALREADY read as titles, while
445
+ Entries are normalized at construction -- lowercased, NFC-composed,
446
+ edge full stops stripped -- so matching is case-insensitive.
447
+ Vocabulary entries are single words -- a multi-word entry warns at
448
+ construction and can never match. Two fields are exempt, and they
449
+ differ in HOW they match: ``given_name_titles`` is looked up
450
+ against the run of words the parse has ALREADY read as titles --
451
+ the whole run space-joined, or that run's last word -- while
332
452
  ``maiden_markers`` is matched by lookahead, longest first, over
333
453
  words that need not be markers on their own (``"z domu"``).
334
454
  Field docs below show examples, not full
@@ -17,11 +17,12 @@ from nameparser._lexicon import Lexicon
17
17
  from nameparser._locale import Locale
18
18
  from nameparser._pipeline import run
19
19
  from nameparser._pipeline._assemble import assemble
20
+ from nameparser._pipeline._post_rules import suffix_entries
20
21
  from nameparser._pipeline._state import ParseState
21
22
  from nameparser._pipeline._vocab import _SCRIPT_MATCHERS
22
23
  from nameparser._policy import UNSET, Policy, PolicyPatch, _Unset, apply_patch
23
24
  from nameparser._types import (
24
- FOLDED_TAG, ParsedName, Segmenter, Token, _guarded_getstate,
25
+ FOLDED_TAG, ParsedName, Role, Segmenter, Token, _guarded_getstate,
25
26
  _guarded_setstate, _validated_field_strings,
26
27
  )
27
28
 
@@ -165,16 +166,30 @@ class Parser:
165
166
  capitalized()) behave as if the text had been parsed. The
166
167
  value is classified ON ITS OWN, though -- a word whose reading
167
168
  depends on surrounding context may classify differently than
168
- it would in place (a standalone "B. S." reads as initials, not
169
- a suffix run). The sub-parse's role choices and ambiguities
170
- are discarded -- every harvested token takes the named field's
171
- role -- and its structural behavior applies: delimiter
172
- characters do not become tokens, and a maiden marker is
173
- consumed as in parsing -- mid-value always, and leading a
174
- DELIMITED value under a policy routing that pair to maiden,
175
- where "(née Jones)" revises to "Jones" while the bare
176
- "née Jones" keeps its marker, a leading marker in an
169
+ it would in place, and a glued CJK honorific the whole name
170
+ kept on an initial may peel in the value. The sub-parse's role
171
+ choices and ambiguities are discarded -- every harvested token
172
+ takes the named field's role -- and its structural behavior
173
+ applies: delimiter characters do not become tokens, and a
174
+ maiden marker is consumed as in parsing -- mid-value always,
175
+ and leading a DELIMITED value under a policy routing that pair
176
+ to maiden, where "(née Jones)" revises to "Jones" while the
177
+ bare "née Jones" keeps its marker, a leading marker in an
177
178
  undelimited value being no marker at all (#329).
179
+ A suffix value's ENTRY structure is derived from the value's
180
+ own commas after the role is forced, by the rule a whole name
181
+ uses: a comma parts two credentials and a space joins them, so
182
+ ``revise(n, suffix="MD PhD")`` is one entry and a name's
183
+ rendered suffix revises back to itself wherever the value's
184
+ words read as the whole name read them -- the honorific peel
185
+ above is the one corpus exception of 368 suffix-bearing names,
186
+ 2026-09-06 (#511). A delimiter the policy names through
187
+ ``extra_suffix_delimiters`` parts a value only where the
188
+ value's own words, read as a name, give it a tail segment for
189
+ the core to be dropped on; a run of post-nominals has none,
190
+ with or without a comma of its own (``"MD PhD - FACS"`` and
191
+ ``"MD, PhD - FACS"`` both keep the dash as a word), so write a
192
+ comma at the boundary you want rather than the delimiter.
178
193
  Tokens are synthetic (span=None); original is unchanged; a
179
194
  value with no name content (empty, whitespace, or punctuation
180
195
  only) clears the field; ambiguities referencing replaced
@@ -182,12 +197,46 @@ class Parser:
182
197
  if not isinstance(name, ParsedName):
183
198
  raise TypeError(f"revise() takes a ParsedName, got {name!r}")
184
199
  replaced = _validated_field_strings(fields)
185
- harvested = {
186
- role: tuple(
200
+ harvested: dict[Role, tuple[Token, ...]] = {}
201
+ for role, value in replaced.items():
202
+ # the same construction parse() makes, spelled twice
203
+ # rather than through a helper: routing parse() through
204
+ # one cost a frame per parse on the hot path (py3.11,
205
+ # 2026-09-06: 415 calls/name against 414 without it, in a
206
+ # 402-418 band), the same trade _mark_suffix_entries
207
+ # refused, one row over
208
+ state = run(ParseState(original=value, lexicon=self.lexicon,
209
+ policy=self.policy,
210
+ segmenter=self.segmenter))
211
+ dropped = set(state.dropped)
212
+ # Force the role BEFORE the entry pass: it keys on
213
+ # Role.SUFFIX, and a bare value's sub-parse reads its words
214
+ # as a name ('MD PhD' is a title and a family there), so
215
+ # inside the sub-parse it joined nothing. The sub-parse's
216
+ # tags are KEPT, "joined" included: a within-piece mark
217
+ # (the Ph. D. merge) is role-blind and right for every
218
+ # role, and a clear was measured and backed out --
219
+ # decisions.md#C1 (2026-09-06 #511) carries the rest: the
220
+ # between-piece mark that rides onto a non-suffix role, and
221
+ # which views read it. Dropped tokens keep their role: the
222
+ # pass filters them by index and assemble omits them.
223
+ forced = tuple(
224
+ tok if i in dropped
225
+ else dataclasses.replace(tok, role=role)
226
+ for i, tok in enumerate(state.tokens))
227
+ # rules.md#R1: "a run of post-nominals written with spaces
228
+ # renders with spaces, and one written with commas keeps
229
+ # them" -- the same pass post_rules runs last, over the
230
+ # value's own comma offsets, so the harvest carries exactly
231
+ # the entry structure the value's commas describe. Run for
232
+ # every role: for a non-suffix role it is a no-op, kept
233
+ # unconditional as the simpler contract, revise() being off
234
+ # the call-count band.
235
+ entried = suffix_entries(
236
+ dataclasses.replace(state, tokens=forced))
237
+ harvested[role] = tuple(
187
238
  Token(t.text, None, role, t.tags - {FOLDED_TAG})
188
- for t in self.parse(value).tokens)
189
- for role, value in replaced.items()
190
- }
239
+ for t in assemble(entried).tokens)
191
240
  return name._with_field_tokens(harvested)
192
241
 
193
242
  # -- comparison -------------------------------------------------------