nameparser 2.0.0rc1__tar.gz → 2.0.0rc2__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 (103) hide show
  1. {nameparser-2.0.0rc1/nameparser.egg-info → nameparser-2.0.0rc2}/PKG-INFO +1 -1
  2. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/__init__.py +2 -0
  3. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_config_shim.py +44 -2
  4. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_facade.py +6 -1
  5. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_lexicon.py +70 -7
  6. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_parser.py +63 -1
  7. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_policy.py +61 -13
  8. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_types.py +91 -39
  9. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_version.py +1 -1
  10. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/suffixes.py +0 -7
  11. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/titles.py +3 -1
  12. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2/nameparser.egg-info}/PKG-INFO +1 -1
  13. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_titles.py +13 -0
  14. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_config_shim.py +39 -0
  15. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_facade.py +17 -0
  16. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_lexicon.py +94 -1
  17. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_locales.py +29 -1
  18. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_parser.py +122 -1
  19. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_policy.py +40 -0
  20. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_properties.py +15 -5
  21. nameparser-2.0.0rc2/tests/v2/test_reprs.py +133 -0
  22. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_types.py +66 -0
  23. nameparser-2.0.0rc1/tests/v2/test_reprs.py +0 -68
  24. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/AUTHORS +0 -0
  25. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/LICENSE +0 -0
  26. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/MANIFEST.in +0 -0
  27. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/README.rst +0 -0
  28. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/__main__.py +0 -0
  29. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_locale.py +0 -0
  30. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/__init__.py +0 -0
  31. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_assemble.py +0 -0
  32. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_assign.py +0 -0
  33. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_classify.py +0 -0
  34. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_extract.py +0 -0
  35. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_group.py +0 -0
  36. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_post_rules.py +0 -0
  37. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_segment.py +0 -0
  38. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_state.py +0 -0
  39. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_tokenize.py +0 -0
  40. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_pipeline/_vocab.py +0 -0
  41. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/_render.py +0 -0
  42. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/__init__.py +0 -0
  43. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/_invariants.py +0 -0
  44. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/bound_first_names.py +0 -0
  45. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/capitalization.py +0 -0
  46. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/conjunctions.py +0 -0
  47. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/maiden_markers.py +0 -0
  48. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/prefixes.py +0 -0
  49. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/config/regexes.py +0 -0
  50. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/locales/__init__.py +0 -0
  51. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/locales/ru.py +0 -0
  52. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/locales/tr_az.py +0 -0
  53. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/parser.py +0 -0
  54. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/py.typed +0 -0
  55. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser/util.py +0 -0
  56. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser.egg-info/SOURCES.txt +0 -0
  57. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser.egg-info/dependency_links.txt +0 -0
  58. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/nameparser.egg-info/top_level.txt +0 -0
  59. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/pyproject.toml +0 -0
  60. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/setup.cfg +0 -0
  61. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/__init__.py +0 -0
  62. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/base.py +0 -0
  63. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/conftest.py +0 -0
  64. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_bound_first_names.py +0 -0
  65. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_brute_force.py +0 -0
  66. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_capitalization.py +0 -0
  67. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_comma_variants.py +0 -0
  68. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_conjunctions.py +0 -0
  69. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_constants.py +0 -0
  70. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_east_slavic_patronymic_order.py +0 -0
  71. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_first_name.py +0 -0
  72. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_initials.py +0 -0
  73. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_middle_name_as_last.py +0 -0
  74. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_nicknames.py +0 -0
  75. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_output_format.py +0 -0
  76. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_prefixes.py +0 -0
  77. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_python_api.py +0 -0
  78. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_suffixes.py +0 -0
  79. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_turkic_patronymic_order.py +0 -0
  80. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/test_variations.py +0 -0
  81. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/__init__.py +0 -0
  82. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/cases.py +0 -0
  83. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/conftest.py +0 -0
  84. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/__init__.py +0 -0
  85. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_assemble.py +0 -0
  86. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_assign.py +0 -0
  87. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_classify.py +0 -0
  88. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_extract.py +0 -0
  89. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_group.py +0 -0
  90. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_post_rules.py +0 -0
  91. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_segment.py +0 -0
  92. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_state.py +0 -0
  93. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_tokenize.py +0 -0
  94. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/pipeline/test_vocab.py +0 -0
  95. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_benchmark.py +0 -0
  96. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_cases.py +0 -0
  97. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_cli.py +0 -0
  98. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_contracts.py +0 -0
  99. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_facade_cases.py +0 -0
  100. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_layering.py +0 -0
  101. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_locale.py +0 -0
  102. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_regex_sync.py +0 -0
  103. {nameparser-2.0.0rc1 → nameparser-2.0.0rc2}/tests/v2/test_render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 2.0.0rc1
3
+ Version: 2.0.0rc2
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
@@ -20,6 +20,7 @@ from nameparser._policy import (
20
20
  PolicyPatch,
21
21
  )
22
22
  from nameparser._types import (
23
+ STABLE_TAGS,
23
24
  Ambiguity,
24
25
  AmbiguityKind,
25
26
  ParsedName,
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "HumanName",
34
35
  # v2 core
35
36
  "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName",
37
+ "STABLE_TAGS",
36
38
  "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET",
37
39
  "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST",
38
40
  "DEFAULT_NICKNAME_DELIMITERS", "Locale",
@@ -33,6 +33,28 @@ from nameparser._policy import PatronymicRule, Policy
33
33
  from nameparser.util import lc
34
34
 
35
35
 
36
+ #: The eight multi-word entries the pre-2.0 DEFAULT vocabulary shipped.
37
+ #: Provably inert in every release (they can never match; see the
38
+ #: 2.0.0 release log), so dropping them from a restored legacy pickle
39
+ #: changes no parse -- and keeps the multi-word warning from firing
40
+ #: eight times, with wrong advice, at library-internal lines, on the
41
+ #: first parse after a supported 1.3/1.4 pickle upgrade.
42
+ #:
43
+ #: Gated on ALL EIGHT being present across the two fields -- the
44
+ #: signature of a pre-2.0 blob, which froze the complete shipped set.
45
+ #: A round-trip of 2.0-era state that carries FEWER than all eight
46
+ #: keeps them (a user who deliberately re-added one or two does not
47
+ #: match the signature, and .copy() never subtracts either). The trade:
48
+ #: a 2.0 user who re-adds ALL eight exact strings is indistinguishable
49
+ #: from a legacy blob and loses them on the next unpickle.
50
+ _LEGACY_DEAD_ENTRIES = {
51
+ "titles": frozenset({"chargé d'affaires"}),
52
+ "suffix_acronyms": frozenset({
53
+ "leed ap", "nicet i", "nicet ii", "nicet iii", "nicet iv",
54
+ "psm i", "psm ii"}),
55
+ }
56
+
57
+
36
58
  def _reject_bare_str_or_bytes(value: object, expected: str) -> None:
37
59
  # A bare string is an iterable of its characters, so e.g. SetManager('dr')
38
60
  # would silently shred it into {'d', 'r'} instead of raising -- shared by
@@ -1103,10 +1125,30 @@ class Constants:
1103
1125
  self.__init__() # type: ignore[misc] # defaults, then overlay
1104
1126
  # (managers re-wrapped below so _on_change points at THIS
1105
1127
  # instance, not whatever produced the incoming state)
1128
+ managers: dict[str, SetManager] = {}
1106
1129
  for name in _SET_FIELDS:
1107
1130
  if name in state:
1108
- object.__setattr__(self, name, SetManager(
1109
- state[name], _on_change=self._bump)) # type: ignore[arg-type]
1131
+ managers[name] = SetManager(
1132
+ state[name], _on_change=self._bump) # type: ignore[arg-type]
1133
+ # SetManager normalized on construction, so the frozen 1.3/1.4
1134
+ # vocabulary's dead entries are matchable in their normalized
1135
+ # spelling here. Subtract only when ALL EIGHT are present --
1136
+ # the pre-2.0 signature; a 2.0 user who re-added one or two
1137
+ # keeps them through a round-trip (see _LEGACY_DEAD_ENTRIES).
1138
+ legacy = all(
1139
+ name in managers and entry in managers[name]
1140
+ for name, entries in _LEGACY_DEAD_ENTRIES.items()
1141
+ for entry in entries
1142
+ )
1143
+ for name, manager in managers.items():
1144
+ if legacy:
1145
+ # Reach past the public discard() deliberately: this is
1146
+ # part of restoring the state, not a mutation of it, and
1147
+ # must not bump the generation of an instance that is
1148
+ # still being built.
1149
+ manager._elements -= _LEGACY_DEAD_ENTRIES.get(
1150
+ name, frozenset())
1151
+ object.__setattr__(self, name, manager)
1110
1152
  if "capitalization_exceptions" in state:
1111
1153
  object.__setattr__(
1112
1154
  self, "capitalization_exceptions", TupleManager(
@@ -335,6 +335,8 @@ class HumanName:
335
335
  else:
336
336
  raise TypeError(
337
337
  f"{member} must be a str, list, or None, got {value!r}")
338
+ # v1 setters stay on replace(): revise()'s vocabulary tags would
339
+ # change v1 parity
338
340
  self._parsed = self._parsed.replace(
339
341
  **{_V2_FIELD.get(member, member): joined})
340
342
 
@@ -616,7 +618,10 @@ class HumanName:
616
618
  "slicing a HumanName was removed in 2.0 (#258); access "
617
619
  "the named attributes instead"
618
620
  )
619
- return getattr(self, key)
621
+ # Role is a StrEnum, so Role members (and the plain 'given'/
622
+ # 'family' strings) reach here too -- translate to the v1
623
+ # spelling the facade actually exposes as attributes.
624
+ return getattr(self, _V1_SPELLING.get(key, key))
620
625
 
621
626
  def as_dict(self, include_empty: bool = True) -> dict[str, str]:
622
627
  """The seven v1-named components as a dict; include_empty=False
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
 
10
10
  import dataclasses
11
11
  import functools
12
+ import sys
13
+ import warnings
12
14
  from collections.abc import Iterable, Mapping
13
15
  from dataclasses import dataclass, field
14
- from types import MappingProxyType
16
+ from types import FrameType, MappingProxyType
15
17
  from typing import cast
16
18
 
17
19
  #: Vocabulary set fields, in declaration order. add()/remove() operate
@@ -112,7 +114,27 @@ def _reject_buffer(value: object, label: str, plural: str) -> None:
112
114
  )
113
115
 
114
116
 
115
- def _normset(entries: Iterable[str], field_name: str) -> frozenset[str]:
117
+ def _warn_dead_entry(message: str) -> None:
118
+ # A fixed stacklevel always lands on library internals: the call
119
+ # depth differs per entry point (Lexicon(), add(), unpickle,
120
+ # dataclasses.replace, and the v1 shim's lazy snapshot -- built on
121
+ # the first parse after a Constants mutation, several facade frames
122
+ # below the user's own add()). Walk out of this module (and
123
+ # dataclasses' replace frames, and the facade layer that builds
124
+ # lexicons on the caller's behalf) so the warning points at the
125
+ # caller's own line.
126
+ level = 2
127
+ frame: FrameType | None = sys._getframe(1)
128
+ while frame is not None and frame.f_globals.get("__name__") in (
129
+ __name__, "dataclasses",
130
+ "nameparser._config_shim", "nameparser._facade"):
131
+ frame, level = frame.f_back, level + 1
132
+ warnings.warn(message, UserWarning, stacklevel=level)
133
+
134
+
135
+ def _normset(
136
+ entries: Iterable[str], field_name: str, warn: bool = True,
137
+ ) -> frozenset[str]:
116
138
  # Reject a bare str before iterating: iterating "dr" would silently
117
139
  # yield the single characters {'d', 'r'} -- the set(str) footgun on
118
140
  # the primary customization surface.
@@ -159,6 +181,25 @@ def _normset(entries: Iterable[str], field_name: str) -> frozenset[str]:
159
181
  f"Lexicon.{field_name} entry {w!r} normalizes to empty "
160
182
  f"(lowercase + strip periods/whitespace leaves nothing)"
161
183
  )
184
+ # Every field but given_name_titles is matched one word at a
185
+ # time, so a multi-word entry can never match -- the library
186
+ # itself shipped eight such dead entries for years (repaired
187
+ # 2026-07-26). Warn, never raise: an inert entry produces
188
+ # nothing, and the given_name_titles precedent says a raise
189
+ # here costs working configurations (see __post_init__).
190
+ # warn=False is _edit()'s pass (both ops): add() warns via the
191
+ # new instance's __post_init__; remove() stores nothing, so
192
+ # warning there would name entries the caller is trying to get
193
+ # RID of, with "split it" advice that makes no sense for a
194
+ # no-op.
195
+ if (warn and field_name != "given_name_titles"
196
+ # interior whitespace test; split() covers all Unicode
197
+ # whitespace
198
+ and n != "".join(n.split())):
199
+ _warn_dead_entry(
200
+ f"Lexicon.{field_name} entries are matched one word at "
201
+ f"a time; multi-word entry {w!r} can never match. "
202
+ f"Split it into separate entries")
162
203
  normalized.add(n)
163
204
  return frozenset(normalized)
164
205
 
@@ -214,6 +255,14 @@ def _normpairs(
214
255
  f"empty (lowercase + strip periods/whitespace leaves "
215
256
  f"nothing)"
216
257
  )
258
+ # capitalized() looks words up one at a time (the _WORD regex
259
+ # never yields spaces), so a multi-word key is unreachable.
260
+ # interior whitespace test; split() covers all Unicode whitespace
261
+ if normalized_key != "".join(normalized_key.split()):
262
+ _warn_dead_entry(
263
+ f"capitalization_exceptions keys are matched one word "
264
+ f"at a time; multi-word key {k!r} can never match. "
265
+ f"Split it into per-word entries")
217
266
  deduped[normalized_key] = v
218
267
  return tuple(sorted(deduped.items()))
219
268
 
@@ -226,9 +275,12 @@ class Lexicon:
226
275
  :meth:`empty`, derive variants with :meth:`add` / :meth:`remove` /
227
276
  ``|`` (union), and pass the result to ``Parser(lexicon=...)``.
228
277
  Entries are normalized at construction -- lowercased, edge periods
229
- stripped -- so matching is case-insensitive. Field docs below show
230
- examples, not full contents; inspect any field's shipped vocabulary
231
- directly, e.g. ``Lexicon.default().conjunctions``."""
278
+ stripped -- so matching is case-insensitive. Vocabulary entries are
279
+ single words -- a multi-word entry warns at construction and can
280
+ never match (``given_name_titles``, matched as a space-joined run,
281
+ is the one exception). Field docs below show examples, not full
282
+ contents; inspect any field's shipped vocabulary directly, e.g.
283
+ ``Lexicon.default().conjunctions``."""
232
284
 
233
285
  #: Pre-nominal titles ("dr", "sir", "capt", ...). Full default
234
286
  #: list: :data:`~nameparser.config.titles.TITLES`.
@@ -256,7 +308,8 @@ class Lexicon:
256
308
  particles: frozenset[str] = frozenset()
257
309
  #: Subset of particles that can also BE a given name: a leading
258
310
  #: one reads as given and records a particle-or-given ambiguity
259
- #: ("Van Johnson"). No constant of its own -- the default derives
311
+ #: ("Van Johnson", but also "Van Buren"). No constant of its own
312
+ #: -- the default derives
260
313
  #: as particles minus
261
314
  #: :data:`~nameparser.config.prefixes.NON_FIRST_NAME_PREFIXES`
262
315
  #: (which marks the opposite, never-given subset).
@@ -479,7 +532,17 @@ class Lexicon:
479
532
  f"{', '.join(_VOCAB_FIELDS)}"
480
533
  )
481
534
  current: frozenset[str] = getattr(self, name)
482
- normalized = _normset(words, name)
535
+ # warn=False: this pass only computes the new set membership,
536
+ # never stores it directly. add() still warns exactly once,
537
+ # from the replaced instance's own __post_init__ -> _normset;
538
+ # remove() never reaches __post_init__ with the dead entry
539
+ # (it is subtracted out here), so it stays silent -- correct,
540
+ # since a removal stores nothing a warning could be about.
541
+ # That silence covers the entry BEING REMOVED only: a
542
+ # different multi-word entry still stored re-warns from the
543
+ # derived instance's __post_init__, since the warning is
544
+ # per-construction by design.
545
+ normalized = _normset(words, name, warn=False)
483
546
  updates[name] = (current | normalized if op == "add"
484
547
  else current - normalized)
485
548
  # mypy's dataclasses.replace() typing checks a **dict's single
@@ -19,7 +19,10 @@ from nameparser._pipeline import run
19
19
  from nameparser._pipeline._assemble import assemble
20
20
  from nameparser._pipeline._state import ParseState
21
21
  from nameparser._policy import UNSET, Policy, PolicyPatch, apply_patch
22
- from nameparser._types import ParsedName, _guarded_getstate, _guarded_setstate
22
+ from nameparser._types import (
23
+ FOLDED_TAG, ParsedName, Token, _guarded_getstate, _guarded_setstate,
24
+ _validated_field_strings,
25
+ )
23
26
 
24
27
 
25
28
  @dataclass(frozen=True, slots=True)
@@ -75,6 +78,65 @@ class Parser:
75
78
  policy=self.policy)
76
79
  return assemble(run(state))
77
80
 
81
+ # -- editing ----------------------------------------------------------
82
+
83
+ def revise(self, name: ParsedName, **fields: str) -> ParsedName:
84
+ """:meth:`ParsedName.replace` with this parser's vocabulary:
85
+ each value is tokenized and classified by a full sub-parse, so
86
+ the stable tags survive and the tag-driven views
87
+ (family_particles, initials(), the suffix join) behave as if
88
+ the text had been parsed. The value is classified ON ITS OWN,
89
+ though -- a word whose reading depends on surrounding context
90
+ may classify differently than it would in place (a standalone
91
+ "B. S." reads as initials, not a suffix run). The sub-parse's
92
+ role choices and ambiguities are discarded -- every harvested
93
+ token takes the named field's role -- and its structural
94
+ behavior applies: delimiter characters do not become tokens,
95
+ and a mid-value maiden marker is consumed as in parsing.
96
+ Tokens are synthetic (span=None); original is unchanged; a
97
+ value with no name content (empty, whitespace, or punctuation
98
+ only) clears the field; ambiguities referencing replaced
99
+ tokens are dropped."""
100
+ if not isinstance(name, ParsedName):
101
+ raise TypeError(f"revise() takes a ParsedName, got {name!r}")
102
+ replaced = _validated_field_strings(fields)
103
+ harvested = {
104
+ role: tuple(
105
+ Token(t.text, None, role, t.tags - {FOLDED_TAG})
106
+ for t in self.parse(value).tokens)
107
+ for role, value in replaced.items()
108
+ }
109
+ return name._with_field_tokens(harvested)
110
+
111
+ # -- comparison -------------------------------------------------------
112
+
113
+ def matches(self, a: str | ParsedName, b: str | ParsedName) -> bool:
114
+ """Component-wise case-insensitive comparison of two names,
115
+ parsing str arguments with THIS parser.
116
+ :meth:`ParsedName.matches` parses its str argument with the
117
+ DEFAULT parser instead -- for names parsed with a custom
118
+ Parser, use this method."""
119
+ if isinstance(a, str):
120
+ a = self.parse(a)
121
+ elif not isinstance(a, ParsedName):
122
+ raise TypeError(f"matches() takes str or ParsedName, got {a!r}")
123
+ if isinstance(b, str):
124
+ b = self.parse(b)
125
+ elif not isinstance(b, ParsedName):
126
+ raise TypeError(f"matches() takes str or ParsedName, got {b!r}")
127
+ return a.comparison_key() == b.comparison_key()
128
+
129
+ # -- rendering delegates ----------------------------------------------
130
+
131
+ def capitalized(self, name: ParsedName, *,
132
+ force: bool = False) -> ParsedName:
133
+ """:meth:`ParsedName.capitalized` under THIS parser's lexicon.
134
+ The no-argument form of that method uses the DEFAULT lexicon --
135
+ for names parsed with a custom Parser, use this method."""
136
+ if not isinstance(name, ParsedName):
137
+ raise TypeError(f"capitalized() takes a ParsedName, got {name!r}")
138
+ return name.capitalized(self.lexicon, force=force)
139
+
78
140
 
79
141
  @functools.cache
80
142
  def _default_parser() -> Parser:
@@ -45,7 +45,32 @@ FAMILY_FIRST = (Role.FAMILY, Role.GIVEN, Role.MIDDLE)
45
45
  #: ``Policy(name_order=...)`` values.
46
46
  FAMILY_FIRST_GIVEN_LAST = (Role.FAMILY, Role.MIDDLE, Role.GIVEN)
47
47
 
48
- _NAME_ROLES = frozenset({Role.GIVEN, Role.MIDDLE, Role.FAMILY})
48
+ _ORDER_CONSTANT_NAMES: dict[tuple[Role, ...], str] = {
49
+ GIVEN_FIRST: "GIVEN_FIRST",
50
+ FAMILY_FIRST: "FAMILY_FIRST",
51
+ FAMILY_FIRST_GIVEN_LAST: "FAMILY_FIRST_GIVEN_LAST",
52
+ }
53
+
54
+
55
+ def _order_repr(value: tuple[Role, ...]) -> str:
56
+ # Unreachable via Policy's constructor (its __post_init__ restricts
57
+ # name_order to the three named orders) but REACHABLE via
58
+ # PolicyPatch, which defers name_order validation to apply time by
59
+ # design -- value may hold non-Role, even unhashable, elements. A
60
+ # value smuggled in through __setstate__ (which validates layout,
61
+ # not values) can also be a non-tuple container or not iterable at
62
+ # all. repr must never raise, so the named-lookup path is taken
63
+ # only for a TUPLE whose every element is confirmed a Role;
64
+ # everything else renders via repr(value). (The annotation states
65
+ # the Policy-side truth; the PolicyPatch call site passes
66
+ # getattr-Any.)
67
+ if isinstance(value, tuple) and all(isinstance(r, Role) for r in value):
68
+ named = _ORDER_CONSTANT_NAMES.get(value)
69
+ if named is not None:
70
+ return named
71
+ return "(" + ", ".join(r.name for r in value) + ")"
72
+ return repr(value)
73
+
49
74
 
50
75
  # Single source for the migration hint raised by both Policy and
51
76
  # PolicyPatch when patronymic_rules gets a non-iterable (True is the
@@ -225,6 +250,10 @@ class Policy:
225
250
  def __post_init__(self) -> None:
226
251
  _reject_bare_string_order(self.name_order)
227
252
  order = tuple(_require_iterable(self.name_order, "name_order"))
253
+ # Sole rejection point for plain-string tuples: Role is a StrEnum,
254
+ # so the named-order membership check below compares EQUAL for
255
+ # ("given", "middle", "family") -- do not remove this loop as
256
+ # redundant.
228
257
  for element in order:
229
258
  if not isinstance(element, Role):
230
259
  raise TypeError(
@@ -326,28 +355,32 @@ class Policy:
326
355
  def __repr__(self) -> str:
327
356
  # Bounded: only fields that deviate from the default are shown
328
357
  # (design rule, see nameparser._types module docstring).
329
- constant_names = {
330
- GIVEN_FIRST: "GIVEN_FIRST",
331
- FAMILY_FIRST: "FAMILY_FIRST",
332
- FAMILY_FIRST_GIVEN_LAST: "FAMILY_FIRST_GIVEN_LAST",
333
- }
334
358
  parts = []
335
359
  for f in dataclasses.fields(self):
336
360
  value = getattr(self, f.name)
337
361
  if value == f.default:
338
362
  continue
339
363
  if f.name == "name_order":
340
- # __post_init__ restricts to the three named orders, so
341
- # the fallback is unreachable via the constructor; kept
342
- # because repr must never raise (e.g. a smuggled
343
- # __setstate__ value -- layout is validated, values not).
344
- order_repr = constant_names.get(
345
- value, "(" + ", ".join(r.name for r in value) + ")")
346
- parts.append(f"name_order={order_repr}")
364
+ parts.append(f"name_order={_order_repr(value)}")
347
365
  else:
348
366
  parts.append(f"{f.name}={value!r}")
349
367
  return f"Policy({', '.join(parts)})"
350
368
 
369
+ # -- editing ------------------------------------------------------
370
+
371
+ def patched(self, patch: PolicyPatch) -> Policy:
372
+ """Fold a :class:`PolicyPatch` onto this Policy and return the
373
+ combined Policy. Set-valued fields union with the patch's;
374
+ scalar fields are overridden by the patch; UNSET fields are
375
+ left alone. Patch VALUES are validated here (Policy's
376
+ constructor re-runs on the result), not at patch construction
377
+ -- see PolicyPatch. The maiden-wins canonicalization applies
378
+ to the combined result exactly as if it had been constructed
379
+ directly."""
380
+ if not isinstance(patch, PolicyPatch):
381
+ raise TypeError(f"patched() takes a PolicyPatch, got {patch!r}")
382
+ return apply_patch(self, patch)
383
+
351
384
 
352
385
  class _Unset(Enum):
353
386
  UNSET = auto()
@@ -432,6 +465,21 @@ class PolicyPatch:
432
465
  # class docstring ("Values are validated when the patch is
433
466
  # applied... not at patch construction").
434
467
 
468
+ def __repr__(self) -> str:
469
+ # Bounded: only fields the patch actually sets are shown; UNSET
470
+ # fields are omitted (design rule, see nameparser._types module
471
+ # docstring -- the sibling of Policy's deviation-only repr).
472
+ parts = []
473
+ for f in dataclasses.fields(self):
474
+ value = getattr(self, f.name)
475
+ if value is UNSET:
476
+ continue
477
+ if f.name == "name_order":
478
+ parts.append(f"name_order={_order_repr(value)}")
479
+ else:
480
+ parts.append(f"{f.name}={value!r}")
481
+ return f"PolicyPatch({', '.join(parts)})"
482
+
435
483
 
436
484
  def apply_patch(policy: Policy, patch: PolicyPatch) -> Policy:
437
485
  """Fold a PolicyPatch onto a Policy. Policy.__post_init__ re-runs via
@@ -24,12 +24,15 @@ if TYPE_CHECKING:
24
24
  from nameparser._parser import Parser
25
25
 
26
26
 
27
- class Role(Enum):
27
+ class Role(StrEnum):
28
28
  """The seven fields of a parsed name, one per :class:`Token`.
29
29
  Declaration order is the canonical field order everywhere
30
- (``as_dict()``, ``comparison_key()``, rendering). Pass a member to
31
- :meth:`ParsedName.tokens_for`; a member's ``.value`` is the field's
32
- string name (``Role.GIVEN.value == "given"``)."""
30
+ (``as_dict()``, ``comparison_key()``, rendering). A StrEnum, like
31
+ :class:`AmbiguityKind`: members ARE their string values, so
32
+ ``token.role == "given"`` compares directly and
33
+ ``str(Role.GIVEN) == "given"``. Members order as strings, so
34
+ ``sorted()`` yields alphabetical order -- iterate ``Role`` itself
35
+ for the canonical order."""
33
36
 
34
37
  # Declaration order IS the canonical field order (conventions §3):
35
38
  # every listing of the seven fields anywhere derives from this.
@@ -74,10 +77,16 @@ class Span(NamedTuple):
74
77
  )
75
78
 
76
79
 
77
- #: Stable, documented tag vocabulary (API). All other tags are
78
- #: namespaced ("vocab:...", "patronymic:...") and unstable. "joined"
79
- #: marks a continuation token of a merged piece (the ph-d merge) and
80
- #: drives the suffix view's space-vs-comma join.
80
+ #: The four :attr:`Token.tags` values that are stable API.
81
+ #: "particle" marks a word from the particle vocabulary ("de", "van")
82
+ #: wherever it lands -- including a given-name "Van" -- so combine it
83
+ #: with Role.FAMILY (as family_particles does) to get actual family
84
+ #: particles; "conjunction" a joining word ("and", "y"); "initial" an
85
+ #: initial-shaped word ("J.", "Q");
86
+ #: "joined" a continuation of the previous token within one merged
87
+ #: piece ("Ph." + "D."), which the suffix view joins with a space
88
+ #: instead of ", ". Every other tag is namespaced ("vocab:...") and is
89
+ #: unstable debugging provenance -- never match against those.
81
90
  STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"})
82
91
 
83
92
  #: The one sanctioned view-reorder marker (namespaced = unstable API).
@@ -154,10 +163,10 @@ class Token:
154
163
  span: Span | None
155
164
  #: The field this token belongs to.
156
165
  role: Role
157
- #: Classification labels. Exactly the four in STABLE_TAGS
158
- #: ("particle", "conjunction", "initial", "joined") are API;
159
- #: namespaced tags like "vocab:..." are unstable debugging
160
- #: provenance -- never match against them.
166
+ #: Classification labels. Exactly the four members of
167
+ #: :data:`~nameparser.STABLE_TAGS` ("particle", "conjunction",
168
+ #: "initial", "joined") are API; namespaced tags like "vocab:..."
169
+ #: are unstable debugging provenance -- never match against them.
161
170
  tags: frozenset[str] = frozenset()
162
171
 
163
172
  # in the class body so @dataclass(slots=True) keeps them
@@ -253,9 +262,10 @@ class AmbiguityKind(StrEnum):
253
262
  #: Smith B"). Which name part was declined depends on position and
254
263
  #: ``name_order``, so ``detail`` names it rather than the kind.
255
264
  SUFFIX_OR_NAME = "suffix-or-name"
256
- #: A leading ambiguous particle was read as a given name -- "Van
257
- #: Johnson" parses given="Van", but "Van" is also a family-name
258
- #: particle in other names.
265
+ #: A leading ambiguous particle was read as a given name -- the
266
+ #: right call for "Van Johnson" (the actor's given name), the
267
+ #: wrong one for a bare "Van Buren" (the presidential surname);
268
+ #: the two-word shape cannot distinguish them.
259
269
  PARTICLE_OR_GIVEN = "particle-or-given"
260
270
  #: A nickname/maiden delimiter opened without closing (or closed
261
271
  #: without opening); the text was kept as literal name content, so
@@ -317,12 +327,31 @@ class Ambiguity:
317
327
  return f"Ambiguity({self.kind.value!r}: {texts})"
318
328
 
319
329
 
330
+ def _validated_field_strings(fields: dict[str, str]) -> dict[Role, str]:
331
+ """Shared by ParsedName.replace and Parser.revise: validate a
332
+ **fields mapping of role names to replacement strings and key it
333
+ by Role. TypeErrors match replace()'s historical wording."""
334
+ by_value = {role.value: role for role in Role}
335
+ for key, value in fields.items():
336
+ if key not in by_value:
337
+ raise TypeError(
338
+ f"unknown field {key!r}; expected one of "
339
+ f"{', '.join(by_value)}"
340
+ )
341
+ if not isinstance(value, str):
342
+ raise TypeError(
343
+ f"field {key!r} must be a str, got {value!r}"
344
+ )
345
+ return {by_value[k]: v for k, v in fields.items()}
346
+
347
+
320
348
  @dataclass(frozen=True, slots=True)
321
349
  class ParsedName:
322
350
  """The immutable result of parsing one name string. Read the seven
323
351
  fields as strings (``.given``, ``.family``, ...); inspect structure
324
352
  through :attr:`tokens` / :meth:`tokens_for`; correct a parse with
325
- :meth:`replace` (returns a new value); produce output with
353
+ :meth:`replace` (returns a new value; Parser.revise is the
354
+ tag-preserving form); produce output with
326
355
  :meth:`render`, :meth:`initials`, :meth:`capitalized`, or ``str()``.
327
356
 
328
357
  Constructor-enforced invariants: spans ascending, non-overlapping,
@@ -492,10 +521,14 @@ class ParsedName:
492
521
 
493
522
  # -- structured access ----------------------------------------------
494
523
 
495
- def tokens_for(self, role: Role) -> tuple[Token, ...]:
524
+ def tokens_for(self, role: Role | str) -> tuple[Token, ...]:
525
+ """The tokens of one field, in document order. Takes a Role
526
+ member or its string value; anything else raises ValueError
527
+ naming the valid roles."""
528
+ role = _coerce_enum(role, Role, "Role", "roles")
496
529
  return tuple(t for t in self.tokens if t.role is role)
497
530
 
498
- def as_dict(self, include_empty: bool = True) -> dict[str, str]:
531
+ def as_dict(self, *, include_empty: bool = True) -> dict[str, str]:
499
532
  # _text_for handles the suffix ", "-join (single-role SUFFIX call)
500
533
  d = {role.value: self._text_for(role) for role in Role}
501
534
  if not include_empty:
@@ -509,35 +542,49 @@ class ParsedName:
509
542
  synthetic tokens (span=None). Whitespace-splits each value; an
510
543
  empty value clears the field. original is unchanged (provenance).
511
544
  Ambiguities referencing replaced tokens are dropped.
512
- """
513
- by_value = {role.value: role for role in Role}
514
- for key, value in fields.items():
515
- if key not in by_value:
516
- raise TypeError(
517
- f"unknown field {key!r}; expected one of "
518
- f"{', '.join(by_value)}"
519
- )
520
- if not isinstance(value, str):
521
- raise TypeError(
522
- f"field {key!r} must be a str, got {value!r}"
523
- )
524
-
525
- def synthetic(value: str, role: Role) -> list[Token]:
526
- return [Token(word, None, role) for word in value.split()]
527
545
 
528
- replaced = {by_value[k]: v for k, v in fields.items()}
546
+ Replacement tokens carry NO tags, so tag-driven views degrade:
547
+ family_particles empties, particles regain their initials, and
548
+ a multi-word suffix is comma-joined. Parser.revise() is the
549
+ tag-preserving alternative.
550
+ """
551
+ replaced = _validated_field_strings(fields)
552
+ synthetic = {
553
+ role: tuple(Token(word, None, role) for word in value.split())
554
+ for role, value in replaced.items()
555
+ }
556
+ return self._with_field_tokens(synthetic)
557
+
558
+ def _with_field_tokens(
559
+ self, replaced: Mapping[Role, tuple[Token, ...]],
560
+ ) -> ParsedName:
561
+ """Shared tail of replace()/Parser.revise(): splice each role's
562
+ replacement tokens in at the role's first position (appended in
563
+ canonical order when the role had no tokens); drop ambiguities
564
+ whose referents were replaced."""
565
+ # Private contract, made self-enforcing: a token filed under a
566
+ # key that is not its own role is the one way this shared tail
567
+ # could build a semantically wrong ParsedName (the splice keys
568
+ # on the mapping, the views key on the token).
569
+ for role, toks in replaced.items():
570
+ for tok in toks:
571
+ if tok.role is not role:
572
+ raise ValueError(
573
+ f"replacement token {tok.text!r} has role "
574
+ f"{tok.role.value}, not {role.value}"
575
+ )
529
576
  new_tokens: list[Token] = []
530
577
  emitted: set[Role] = set()
531
578
  for tok in self.tokens:
532
579
  if tok.role in replaced:
533
580
  if tok.role not in emitted:
534
- new_tokens.extend(synthetic(replaced[tok.role], tok.role))
581
+ new_tokens.extend(replaced[tok.role])
535
582
  emitted.add(tok.role)
536
583
  continue
537
584
  new_tokens.append(tok)
538
585
  for role in Role:
539
586
  if role in replaced and role not in emitted:
540
- new_tokens.extend(synthetic(replaced[role], role))
587
+ new_tokens.extend(replaced[role])
541
588
  kept = tuple(
542
589
  amb for amb in self.ambiguities
543
590
  if all(t in new_tokens for t in amb.tokens)
@@ -557,7 +604,10 @@ class ParsedName:
557
604
  parser: Parser | None = None) -> bool:
558
605
  """Component-wise case-insensitive comparison (the semantic
559
606
  layer; __eq__ stays strict). A str argument is parsed with
560
- `parser`, or the default parser when None."""
607
+ `parser`, or with the DEFAULT parser when None -- if this name
608
+ came from a custom Parser, pass that parser (or use
609
+ Parser.matches); otherwise the comparison silently runs under
610
+ the wrong configuration."""
561
611
  if isinstance(other, str):
562
612
  import nameparser._parser as _parser
563
613
  active = parser if parser is not None else _parser._default_parser()
@@ -600,7 +650,9 @@ class ParsedName:
600
650
  force: bool = False) -> ParsedName:
601
651
  """Case-fixing transform -> new ParsedName, same spans, new
602
652
  token texts. Needs a lexicon for capitalization_exceptions and
603
- particle rules; None uses the default lexicon. force=False
604
- preserves mixed-case input (v1 parity). Idempotent."""
653
+ particle rules; None uses the DEFAULT lexicon -- if this name
654
+ came from a custom Parser, pass its lexicon or use
655
+ Parser.capitalized. force=False preserves mixed-case input
656
+ (v1 parity). Idempotent."""
605
657
  import nameparser._render as _render
606
658
  return _render.capitalized(self, lexicon, force=force)
@@ -5,5 +5,5 @@ VERSION = (2, 0, 0)
5
5
  #: PEP 440 pre-release/dev segment appended to the numeric version, or
6
6
  #: "" for a final release. Joined WITHOUT a dot ("2.0.0rc1", not
7
7
  #: "2.0.0.rc1"); setuptools reads __version__ as the package version.
8
- PRE_RELEASE = "rc1"
8
+ PRE_RELEASE = "rc2"
9
9
  __version__ = ".".join(map(str, VERSION)) + PRE_RELEASE