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.
- {nameparser-2.2.0/nameparser.egg-info → nameparser-2.3.0}/PKG-INFO +1 -1
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_facade.py +31 -12
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_lexicon.py +156 -36
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_parser.py +64 -15
- nameparser-2.3.0/nameparser/_pipeline/_assign.py +718 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_classify.py +5 -3
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_group.py +97 -108
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_pieces.py +202 -12
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_post_rules.py +237 -28
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_script_segment.py +103 -32
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_state.py +10 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_vocab.py +55 -16
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_policy.py +4 -1
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_types.py +50 -5
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_version.py +2 -2
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/_invariants.py +6 -5
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/suffixes.py +100 -36
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/titles.py +132 -6
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/util.py +3 -2
- {nameparser-2.2.0 → nameparser-2.3.0/nameparser.egg-info}/PKG-INFO +1 -1
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/SOURCES.txt +1 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_capitalization.py +9 -3
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_conjunctions.py +1 -2
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_initials.py +36 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_suffixes.py +68 -17
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_titles.py +26 -7
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/_differential_fixtures.py +12 -1
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/cases.py +2081 -153
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/conftest.py +3 -1
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_assign.py +167 -5
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_group.py +19 -13
- nameparser-2.3.0/tests/v2/pipeline/test_pieces.py +252 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_post_rules.py +319 -12
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_script_segment.py +124 -3
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_state.py +6 -3
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_vocab.py +58 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/rules_doc.py +48 -0
- nameparser-2.3.0/tests/v2/test_cases.py +235 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_contracts.py +2 -0
- nameparser-2.3.0/tests/v2/test_differential.py +4792 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_facade.py +17 -10
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_facade_cases.py +18 -0
- nameparser-2.3.0/tests/v2/test_ledger_guards.py +5077 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_lexicon.py +121 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_locales.py +169 -2
- nameparser-2.3.0/tests/v2/test_order_correspondence.py +206 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_parser.py +217 -21
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_render.py +19 -0
- nameparser-2.3.0/tests/v2/test_rules_doc_grammar.py +172 -0
- nameparser-2.2.0/nameparser/_pipeline/_assign.py +0 -429
- nameparser-2.2.0/tests/v2/pipeline/test_pieces.py +0 -103
- nameparser-2.2.0/tests/v2/test_cases.py +0 -72
- nameparser-2.2.0/tests/v2/test_differential.py +0 -1361
- nameparser-2.2.0/tests/v2/test_ledger_guards.py +0 -2452
- nameparser-2.2.0/tests/v2/test_rules_doc_grammar.py +0 -95
- {nameparser-2.2.0 → nameparser-2.3.0}/AUTHORS +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/LICENSE +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/MANIFEST.in +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/README.rst +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/__main__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_config_shim.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_locale.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_assemble.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_extract.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_segment.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_pipeline/_tokenize.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/_render.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/_deprecated.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/bound_first_names.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/bound_given_names.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/capitalization.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/conjunctions.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/maiden_markers.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/particles.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/prefixes.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/regexes.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/config/surnames.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/ja.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/ru.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/tr_az.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/locales/zh.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/parser.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser/py.typed +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/dependency_links.txt +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/requires.txt +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/nameparser.egg-info/top_level.txt +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/pyproject.toml +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/setup.cfg +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/base.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/conftest.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_bound_given_names.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_brute_force.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_comma_variants.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_constants.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_east_slavic_patronymic_order.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_first_name.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_middle_name_as_last.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_nicknames.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_output_format.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_particles.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_python_api.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_turkic_patronymic_order.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/test_variations.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/__init__.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_assemble.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_classify.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_extract.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_segment.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/pipeline/test_tokenize.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_benchmark.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_cli.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_config_aliases.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_config_shim.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_doc_citations.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_doc_spellings.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_layering.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_locale.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_policy.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_properties.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_regex_sync.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_reprs.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_rules_doc.py +0 -0
- {nameparser-2.2.0 → nameparser-2.3.0}/tests/v2/test_types.py +0 -0
|
@@ -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
|
|
516
|
-
# from initials unless the part is a
|
|
517
|
-
# than split(" ")
|
|
518
|
-
#
|
|
519
|
-
#
|
|
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
|
-
|
|
524
|
-
|
|
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
|
|
558
|
-
# compare the seven role fields only
|
|
559
|
-
# initials column
|
|
560
|
-
# tests/test_initials.py::test_initials_middle_name_all_prefixes
|
|
561
|
-
#
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
collapse to 'jr' and hit the
|
|
101
|
-
pinned live 2026-07-17).
|
|
102
|
-
period-free form (see
|
|
103
|
-
is_suffix, which removed
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
127
|
-
"""The
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
327
|
-
stripped -- so matching is case-insensitive.
|
|
328
|
-
single words -- a multi-word entry warns at
|
|
329
|
-
never match. Two fields are exempt, and they
|
|
330
|
-
match: ``given_name_titles`` is looked up
|
|
331
|
-
of words the parse has ALREADY read as titles
|
|
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
|
|
169
|
-
|
|
170
|
-
are discarded -- every harvested token
|
|
171
|
-
role -- and its structural behavior
|
|
172
|
-
characters do not become tokens, and a
|
|
173
|
-
consumed as in parsing -- mid-value always,
|
|
174
|
-
DELIMITED value under a policy routing that pair
|
|
175
|
-
where "(née Jones)" revises to "Jones" while the
|
|
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
|
-
|
|
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
|
|
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 -------------------------------------------------------
|