Skip to content

Commit cc7063f

Browse files
derek73claude
andcommitted
feat: maiden_delimiters wins on overlap; export DEFAULT_NICKNAME_DELIMITERS
Routing parens to maiden used to require editing both delimiter buckets in tandem, with the nickname default's contents discovered by digging: a pair in both buckets silently parsed as nickname. Now Policy canonicalizes the effective nickname set to nickname_delimiters - maiden_delimiters (listing a pair in the specific bucket IS the intent), so Policy(maiden_delimiters={('(', ')')}) is the whole recipe -- same canonicalization precedent as the name_order coercion, equal values converge. The nickname default is now the public, documented DEFAULT_NICKNAME_DELIMITERS constant for explicit set math. The 1.x facade keeps v1's nickname-wins precedence (pinned by the v1 suite) via a shim-side pre-subtraction on the maiden bucket, pinned by test_snapshot_overlap_keeps_v1_nickname_precedence. Case row nickname_bucket_wins_when_shared becomes maiden_delimiters_win_when_shared. Docs: customize one-liner example, migrate precedence note, release-log entry, reference autodata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b809ccd commit cc7063f

11 files changed

Lines changed: 111 additions & 24 deletions

File tree

docs/customize.rst

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,14 @@ listed below.
7474
* - ``nickname_delimiters``
7575
- ``frozenset[tuple[str, str]]``
7676
- Routes content enclosed by these delimiter pairs to
77-
``nickname``. Defaults to quotes and parentheses.
77+
``nickname``. Defaults to
78+
:data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` — quotes and
79+
parentheses.
7880
* - ``maiden_delimiters``
7981
- ``frozenset[tuple[str, str]]``
8082
- Routes content enclosed by these delimiter pairs to ``maiden``
81-
instead. Defaults to empty — see the routing example below.
83+
instead, and drops them from the effective nickname set.
84+
Defaults to empty — see the routing example below.
8285
* - ``extra_suffix_delimiters``
8386
- ``frozenset[str]``
8487
- Adds separators (beyond a comma) that split suffix groups, e.g.
@@ -100,17 +103,16 @@ listed below.
100103
.. doctest::
101104

102105
>>> from nameparser import Parser, Policy
103-
>>> policy = Policy(
104-
... nickname_delimiters=frozenset({("'", "'"), ('"', '"')}),
105-
... maiden_delimiters=frozenset({("(", ")")}),
106-
... )
106+
>>> policy = Policy(maiden_delimiters=frozenset({("(", ")")}))
107107
>>> Parser(policy=policy).parse("Jane (Jones) Smith").maiden
108108
'Jones'
109109

110-
Routing parentheses to ``maiden`` means moving that pair out of
111-
``nickname_delimiters`` as well as into ``maiden_delimiters``, as
112-
above — a delimiter pair present in *both* buckets routes to
113-
``nickname``.
110+
A pair routes to exactly one field, and ``maiden_delimiters`` states
111+
the specific intent — so listing a pair there automatically drops it
112+
from the effective ``nickname_delimiters`` set, and the one-liner
113+
above is the whole recipe. To *add* delimiters instead of rerouting
114+
them, build on the named default:
115+
``nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("«", "»")}``.
114116

115117
Presentation: rendering arguments
116118
----------------------------------

docs/migrate.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,10 @@ rendering argument, where the 2.0 equivalent isn't config at all):
174174
``(open, close)`` pairs
175175
* - ``maiden_delimiters``
176176
- ``Policy.maiden_delimiters``
177-
- Same shape change as ``nickname_delimiters``
177+
- Same shape change as ``nickname_delimiters``. Precedence
178+
differs: in the 2.0 API a pair listed here wins over
179+
``nickname_delimiters``; through the 1.x facade a pair in both
180+
buckets keeps parsing as ``nickname`` (v1 behavior)
178181
* - ``regexes.bidi``
179182
- ``Policy.strip_bidi``
180183
- ``regexes.bidi = False`` becomes ``Policy(strip_bidi=False)``

docs/modules.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Configuration
5858

5959
.. autodata:: nameparser.FAMILY_FIRST_GIVEN_LAST
6060

61+
.. autodata:: nameparser.DEFAULT_NICKNAME_DELIMITERS
62+
6163
Locales
6264
~~~~~~~
6365

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Release Log
2424
- Data change: ``ma``/``do`` added to ``suffix_acronyms_ambiguous`` -- ambiguous acronyms count as suffixes only when written with periods, so a bare common surname (``"Jack Ma"``, ``"Anh Do"``) keeps its family name under 2.0's keep-recognized-suffixes routing, matching 1.4's output (``tests/v2/cases.py`` row ``ambiguous_surname_acronyms``). Side effect: parenthesized/quoted ``"(MA)"``/``"(DO)"`` (no periods) no longer escape to ``suffix`` the way 1.x did -- they now fall through to nickname parsing like any other ambiguous-acronym delimited content. Not present in the differential corpus
2525
- Change suffix-delimiter rendering: with a custom ``Policy``/``Constants`` suffix delimiter configured (e.g. ``suffix_delimiter="/"``, ``"John Smith, RN/CRNA"``), 1.x split the token and rendered ``suffix="RN, CRNA"``; 2.0 keeps the no-space delimiter-core token whole (``suffix="RN/CRNA"``) -- role assignment is unchanged, only rendering differs (anti-#100, migration plan deviation 5; ``tests/v2/cases.py`` row ``suffix_delimiter_no_space_core``). Only fires with a non-default policy, so it does not appear in the (default-policy) differential corpus
2626
- Change ``comparison_key()``/``matches()`` (both APIs) to fold components with ``str.casefold()`` where 1.4 used ``str.lower()``: Unicode case-pair forms now compare equal -- ``"STRASSE"`` matches ``"Straße"``, and a Greek final-sigma variant matches its regular-sigma form. Strictly more permissive (anything 1.4 matched still matches); parse output is unaffected -- vocabulary normalization itself uses ``lower()``, v1-parity (``tests/v2/test_types.py`` row ``test_matches_casefolds_unicode_case_pairs``)
27+
- Change delimiter-overlap precedence in the 2.0 API: a pair listed in ``Policy.maiden_delimiters`` is dropped from the effective ``nickname_delimiters`` set (maiden wins), so ``Policy(maiden_delimiters={("(", ")")})`` alone routes parenthesized content to ``maiden`` -- no need to rebuild the nickname set. The default nickname set is now the public ``DEFAULT_NICKNAME_DELIMITERS`` constant. The 1.x facade keeps v1's nickname-wins precedence on overlap via a shim-side pre-subtraction, so no ``HumanName`` behavior changes (``tests/v2/cases.py`` row ``maiden_delimiters_win_when_shared``; ``tests/v2/test_config_shim.py`` row ``test_snapshot_overlap_keeps_v1_nickname_precedence``)
2728

2829
Everything else in the 486-name differential corpus (built from the
2930
v1 test banks as of commit ``2d5d8c2``, pre-dating the M12 test

nameparser/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from nameparser._locale import Locale
1111
from nameparser._parser import Parser, parse, parser_for
1212
from nameparser._policy import (
13+
DEFAULT_NICKNAME_DELIMITERS,
1314
FAMILY_FIRST,
1415
FAMILY_FIRST_GIVEN_LAST,
1516
GIVEN_FIRST,
@@ -33,6 +34,7 @@
3334
# v2 core
3435
"Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName",
3536
"Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET",
36-
"GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", "Locale",
37+
"GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST",
38+
"DEFAULT_NICKNAME_DELIMITERS", "Locale",
3739
"Parser", "parse", "parser_for",
3840
]

nameparser/_config_shim.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,8 +950,12 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
950950
middle_as_family=self.middle_name_as_last,
951951
nickname_delimiters=frozenset(
952952
_SENTINEL_PAIRS[k] for k in self.nickname_delimiters),
953+
# v1 precedence: a pair in BOTH v1 buckets parses as nickname.
954+
# Policy itself resolves overlap the other way (maiden wins),
955+
# so pre-subtract here to keep the facade at v1 behavior.
953956
maiden_delimiters=frozenset(
954-
_SENTINEL_PAIRS[k] for k in self.maiden_delimiters),
957+
_SENTINEL_PAIRS[k] for k in self.maiden_delimiters
958+
if k not in self.nickname_delimiters),
955959
# suffix_delimiter is a _RenderDefaults-only field here; the
956960
# facade layers it onto extra_suffix_delimiters per instance
957961
# (a later task) -- _snapshot() itself stays pure translation

nameparser/_policy.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ class PatronymicRule(StrEnum):
2727

2828
_NAME_ROLES = frozenset({Role.GIVEN, Role.MIDDLE, Role.FAMILY})
2929

30+
#: Policy.nickname_delimiters' default (v1 parity: quotes + parentheses).
31+
#: Public and named so customizations read as set math against a
32+
#: documented value -- e.g. ``DEFAULT_NICKNAME_DELIMITERS | {("«", "»")}``
33+
#: -- instead of a rebuilt literal the user had to go discover.
34+
DEFAULT_NICKNAME_DELIMITERS = frozenset(
35+
{("'", "'"), ('"', '"'), ("(", ")")})
36+
3037

3138
def _reject_bare_string_order(value: object) -> None:
3239
# tuple("gmf") would be ("g", "m", "f") -- catch the bare string
@@ -45,11 +52,11 @@ class Policy:
4552
patronymic_rules: frozenset[PatronymicRule] = frozenset()
4653
middle_as_family: bool = False # v1's middle_name_as_last
4754
# v1 default delimiter set (#273)
48-
nickname_delimiters: frozenset[tuple[str, str]] = frozenset(
49-
{("'", "'"), ('"', '"'), ("(", ")")}
50-
)
51-
# empty by default (v1 parity); route ("(", ")") here to send
52-
# parenthesized content to maiden instead of nickname (#274)
55+
nickname_delimiters: frozenset[tuple[str, str]] = DEFAULT_NICKNAME_DELIMITERS
56+
# empty by default (v1 parity); a pair listed here routes to maiden
57+
# AND is dropped from the effective nickname set (maiden wins, see
58+
# __post_init__), so maiden_delimiters={("(", ")")} is the whole
59+
# parenthesized-maiden recipe (#274)
5360
maiden_delimiters: frozenset[tuple[str, str]] = frozenset()
5461
extra_suffix_delimiters: frozenset[str] = frozenset()
5562
lenient_comma_suffixes: bool = True
@@ -129,6 +136,16 @@ def __post_init__(self) -> None:
129136
f"strings, got {pair!r}"
130137
)
131138
object.__setattr__(self, pairs_name, frozenset(pairs))
139+
# Maiden wins: a pair can route to exactly one field, and listing
140+
# it in maiden_delimiters is the specific intent, so the effective
141+
# nickname set drops it. Canonicalization, not validation (the
142+
# name_order coercion precedent): differently-written but
143+
# equivalent Policies converge to equal values. The v1 facade
144+
# keeps v1's nickname-wins precedence via a pre-subtraction in
145+
# _config_shim's snapshot instead.
146+
object.__setattr__(
147+
self, "nickname_delimiters",
148+
self.nickname_delimiters - self.maiden_delimiters)
132149
if isinstance(self.extra_suffix_delimiters, str):
133150
raise TypeError(
134151
f"extra_suffix_delimiters must be an iterable of strings, "

tests/v2/cases.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,15 @@ def __post_init__(self) -> None:
117117
notes="v1 renders each tail comma segment as ONE suffix "
118118
"entry; words within an entry space-join via the "
119119
"'joined' tag"),
120-
Case("nickname_bucket_wins_when_shared",
120+
Case("maiden_delimiters_win_when_shared",
121121
'Baker (Johnson), Jenny',
122-
{"given": "Jenny", "family": "Baker", "nickname": "Johnson"},
122+
{"given": "Jenny", "family": "Baker", "maiden": "Johnson"},
123123
policy=Policy(maiden_delimiters=frozenset({("(", ")")})),
124-
notes="when the same delimiter pair sits in both buckets the "
125-
"nickname reading wins (v1 parse_nicknames order); the "
126-
"bucket-move idiom removes it from nickname first"),
124+
notes="listing a pair in maiden_delimiters drops it from the "
125+
"effective nickname set (maiden wins, 2026-07-19) -- the "
126+
"one-liner replaces the bucket-move idiom; the v1 facade "
127+
"keeps v1's nickname-wins precedence via the shim's "
128+
"pre-subtraction (pinned in test_config_shim)"),
127129
Case("family_segment_trailing_suffix", "Smith Jr., John",
128130
{"given": "John", "family": "Smith", "suffix": "Jr."},
129131
notes="v1: the family part may have suffixes in it "

tests/v2/test_config_shim.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,23 @@ def test_snapshot_delimiter_bucket_move() -> None:
361361
assert ("(", ")") not in policy.nickname_delimiters
362362

363363

364+
def test_snapshot_overlap_keeps_v1_nickname_precedence() -> None:
365+
# v1 semantics: a pair present in BOTH v1 buckets parses as nickname.
366+
# 2.0's Policy resolves overlap the other way (maiden wins), so the
367+
# snapshot pre-subtracts on the maiden side -- a v1 user who added
368+
# parens to maiden_delimiters WITHOUT removing them from
369+
# nickname_delimiters keeps their 1.x parse through the facade.
370+
c = Constants()
371+
c.maiden_delimiters["parenthesis"] = ("(", ")") # nickname still has it
372+
_, policy, _ = c._snapshot()
373+
assert ("(", ")") in policy.nickname_delimiters
374+
assert ("(", ")") not in policy.maiden_delimiters
375+
from nameparser import HumanName
376+
377+
n = HumanName("Jane (Jones) Smith", constants=c)
378+
assert n.nickname == "Jones" and n.maiden == ""
379+
380+
364381
def test_snapshot_ambiguous_removed_acronym_intersects() -> None:
365382
c = Constants()
366383
c.suffix_acronyms.remove("jd") # 'jd' stays in ambiguous

tests/v2/test_layering.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ def test_public_exports() -> None:
166166
expected = {
167167
"Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName",
168168
"Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET",
169-
"GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", "Locale",
169+
"GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST",
170+
"DEFAULT_NICKNAME_DELIMITERS", "Locale",
170171
"Parser", "parse", "parser_for",
171172
}
172173
assert expected <= set(nameparser.__all__)

0 commit comments

Comments
 (0)