Skip to content

Commit 7ee6e3a

Browse files
derek73claude
andcommitted
feat: typographic nickname delimiters as defaults (closes #273)
DEFAULT_NICKNAME_DELIMITERS grows by the eight conventions the issue proposed: smart quotes, low-high (de/pl/cs/hu), right-right (sv/fi), guillemets both directions (« Petit » inner spacing tolerated), CJK corner brackets, fullwidth parentheses. Curly single quotes stay excluded (U+2019 is the apostrophe in O'Connor, pinned). Both APIs get them: the shim's sentinel map gains named keys (smart_double_quotes, guillemets, ...) so the v1 keyed idioms (pop/move/del) work on the new pairs like the original trio. The conventions share characters in opposite roles ('"' closes the German pair but opens the English one; '»' closes guillemets and opens the reversed pair), which made extraction emit spurious unbalanced-delimiter ambiguities: extract now tracks unmatched-open offsets and drops candidates inside a region another pair successfully masked. Genuine unbalanced opens still flag. TDD throughout (extract suppression -> defaults -> shim sentinels, each red first); 9 case rows through both runners; differential harness exit 0 unchanged (no typographic quotes in the corpus); release-log feat entry; reference/customize/migrate docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5b21b97 commit 7ee6e3a

12 files changed

Lines changed: 168 additions & 34 deletions

File tree

docs/customize.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ listed below.
8181
- ``frozenset[tuple[str, str]]``
8282
- Routes content enclosed by these delimiter pairs to
8383
``nickname``. Defaults to
84-
:data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` — quotes and
85-
parentheses.
84+
:data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` — straight
85+
quotes and parentheses plus the typographic conventions (smart
86+
quotes, guillemets, CJK brackets, ...).
8687
* - ``maiden_delimiters``
8788
- ``frozenset[tuple[str, str]]``
8889
- Routes content enclosed by these delimiter pairs to ``maiden``

docs/migrate.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ rendering argument, where the 2.0 equivalent isn't config at all):
170170
-
171171
* - ``nickname_delimiters``
172172
- ``Policy.nickname_delimiters``
173-
- Was a three-sentinel dict; now a plain ``frozenset`` of
174-
``(open, close)`` pairs
173+
- Was a dict of named sentinels; now a plain ``frozenset`` of
174+
``(open, close)`` pairs. Both APIs gained the #273 typographic
175+
defaults (smart quotes, guillemets, CJK brackets, ...) in 2.0
175176
* - ``maiden_delimiters``
176177
- ``Policy.maiden_delimiters``
177178
- Same shape change as ``nickname_delimiters``. Precedence

docs/modules.rst

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,17 @@ Delimiter defaults
9393
^^^^^^^^^^^^^^^^^^
9494

9595
.. py:data:: nameparser.DEFAULT_NICKNAME_DELIMITERS
96-
:value: frozenset({("'", "'"), ('"', '"'), ("(", ")")})
97-
98-
The default :attr:`~nameparser.Policy.nickname_delimiters` set —
99-
quotes and parentheses. Build on it for additive customizations,
100-
e.g. ``nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS |
101-
{("«", "»")}``; to *reroute* a pair to ``maiden``, just list it in
96+
:value: frozenset({("'", "'"), ('"', '"'), ("(", ")"), ("“", "”"), ("„", "“"), ("”", "”"), ("«", "»"), ("»", "«"), ("「", "」"), ("『", "』"), ("(", ")")})
97+
98+
The default :attr:`~nameparser.Policy.nickname_delimiters` set:
99+
straight quotes and parentheses plus the typographic conventions —
100+
smart quotes, German/Polish low-high quotes, Swedish right-right
101+
quotes, guillemets in both directions, CJK corner brackets, and
102+
fullwidth parentheses (#273). Curly *single* quotes are deliberately
103+
absent: U+2019 is the typographic apostrophe ("O’Connor"). Build on
104+
the constant for additive customizations, e.g.
105+
``nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}``;
106+
to *reroute* a pair to ``maiden``, just list it in
102107
:attr:`~nameparser.Policy.maiden_delimiters` (it is dropped from
103108
the effective nickname set automatically).
104109

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Release Log
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``)
2727
- 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``)
28+
- Recognize typographic nickname delimiters by default in BOTH APIs (closes #273): smart quotes (``“Jack”``), German/Polish low-high quotes (``„Hansi“``), Swedish right-right quotes (``”Ann”``), guillemets in both directions (``«Petit»``, ``»Hansi«``, inner spacing tolerated), CJK corner brackets (``「タロ」``/``『ハナ』``), and fullwidth parentheses. In 1.x these leaked into ``middle`` as literal text. Curly *single* quotes stay excluded (U+2019 is the apostrophe in "O’Connor" -- pinned by ``curly_apostrophe_stays_literal``). The v1 keyed idioms work on the new named sentinels (``smart_double_quotes``, ``guillemets``, ...). A delimiter character consumed by another pair's extraction no longer emits a spurious ``unbalanced-delimiter`` ambiguity (``tests/v2/cases.py`` rows ``*_nickname``; ``tests/v2/pipeline/test_extract.py``). Not present in the differential corpus
2829

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

nameparser/_config_shim.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,12 +407,24 @@ def __setstate__(self, state: dict[str, object]) -> None:
407407
self._on_change = None # rewired by the owning Constants
408408

409409

410-
#: v1's three named delimiter buckets, translated to the ``Policy``
411-
#: (open, close) pairs they stand for (spec §3).
410+
#: The named delimiter buckets, translated to the ``Policy``
411+
#: (open, close) pairs they stand for (spec §3). The first three are
412+
#: v1's; the rest are the #273 typographic conventions, named so the
413+
#: v1 keyed idioms (pop/move/del) work on them like the originals.
414+
#: Keep in sync with DEFAULT_NICKNAME_DELIMITERS in _policy.py (pinned
415+
#: by the default-Constants equality test).
412416
_SENTINEL_PAIRS = {
413417
"quoted_word": ("'", "'"),
414418
"double_quotes": ('"', '"'),
415419
"parenthesis": ("(", ")"),
420+
"smart_double_quotes": ("“", "”"),
421+
"low_high_quotes": ("„", "“"),
422+
"right_double_quotes": ("”", "”"),
423+
"guillemets": ("«", "»"),
424+
"reversed_guillemets": ("»", "«"),
425+
"corner_brackets": ("「", "」"),
426+
"white_corner_brackets": ("『", "』"),
427+
"fullwidth_parenthesis": ("(", ")"),
416428
}
417429

418430
#: derived, so the manager's accepted keys and _snapshot()'s

nameparser/_pipeline/_extract.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ def extract_delimited(state: ParseState) -> ParseState:
5959
text = state.original
6060
extracted: list[tuple[Role, Span]] = []
6161
masked: list[Span] = []
62-
ambiguities: list[PendingAmbiguity] = []
62+
# candidates, not final: each carries the offset of the unmatched
63+
# open so cross-pair overlaps can be filtered once all pairs ran
64+
unbalanced: list[tuple[int, PendingAmbiguity]] = []
6365
# nickname first (v1 parse_nicknames order): when the same
6466
# delimiter pair sits in BOTH buckets, the nickname reading wins;
6567
# the documented bucket-move idiom removes it from nickname, so
@@ -79,11 +81,11 @@ def extract_delimited(state: ParseState) -> ParseState:
7981
and not _close_ok(text, j, len(close))):
8082
j = text.find(close, j + 1)
8183
if j == -1:
82-
ambiguities.append(PendingAmbiguity(
84+
unbalanced.append((i, PendingAmbiguity(
8385
AmbiguityKind.UNBALANCED_DELIMITER,
8486
f"unmatched {open_!r} at offset {i}; treated as "
8587
f"literal text",
86-
))
88+
)))
8789
if open_ == close:
8890
# _close_ok is open-independent, so a failed
8991
# close-walk means NO boundary-valid close
@@ -94,11 +96,11 @@ def extract_delimited(state: ParseState) -> ParseState:
9496
scan = i + len(open_)
9597
while (k := text.find(open_, scan)) != -1:
9698
if _open_ok(text, k):
97-
ambiguities.append(PendingAmbiguity(
99+
unbalanced.append((k, PendingAmbiguity(
98100
AmbiguityKind.UNBALANCED_DELIMITER,
99101
f"unmatched {open_!r} at offset {k}; "
100102
f"treated as literal text",
101-
))
103+
)))
102104
scan = k + 1
103105
break
104106
pos = i + len(open_)
@@ -125,6 +127,14 @@ def extract_delimited(state: ParseState) -> ParseState:
125127
pos = j + len(close)
126128
extracted.sort(key=lambda pair: pair[1])
127129
masked.sort()
130+
# A delimiter character consumed by another pair's successful
131+
# extraction is literal for every other pair: drop unbalanced
132+
# candidates whose offset lies inside a masked region (#273 -- the
133+
# conventions share characters in opposite roles: '“' closes „…“
134+
# but opens “…”, '»' closes «…» but opens »…«).
135+
ambiguities = tuple(
136+
a for offset, a in unbalanced
137+
if not any(m.start <= offset < m.end for m in masked))
128138
return dataclasses.replace(
129139
state, extracted=tuple(extracted), masked=tuple(masked),
130-
ambiguities=state.ambiguities + tuple(ambiguities))
140+
ambiguities=state.ambiguities + ambiguities)

nameparser/_policy.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,25 @@ class PatronymicRule(StrEnum):
4545

4646
_NAME_ROLES = frozenset({Role.GIVEN, Role.MIDDLE, Role.FAMILY})
4747

48-
#: Policy.nickname_delimiters' default (v1 parity: quotes + parentheses).
49-
#: Public and named so customizations read as set math against a
50-
#: documented value -- e.g. ``DEFAULT_NICKNAME_DELIMITERS | {("«", "»")}``
51-
#: -- instead of a rebuilt literal the user had to go discover.
52-
DEFAULT_NICKNAME_DELIMITERS = frozenset(
53-
{("'", "'"), ('"', '"'), ("(", ")")})
48+
#: Policy.nickname_delimiters' default. Public and named so
49+
#: customizations read as set math against a documented value -- e.g.
50+
#: ``DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}`` -- instead of a
51+
#: rebuilt literal the user had to go discover. The v1 trio (straight
52+
#: quotes + parentheses) plus the typographic conventions (#273):
53+
#: smart quotes, low-high and right-right quotes, guillemets both
54+
#: directions, CJK corner brackets, fullwidth parentheses. Curly
55+
#: SINGLE quotes are deliberately absent: U+2019 is the typographic
56+
#: apostrophe ("O’Connor").
57+
DEFAULT_NICKNAME_DELIMITERS = frozenset({
58+
("'", "'"), ('"', '"'), ("(", ")"), # v1 trio
59+
("“", "”"), # smart quotes (en, zh)
60+
("„", "“"), # low-high (de, pl, cs, hu)
61+
("”", "”"), # right-right (sv, fi)
62+
("«", "»"), # guillemets (fr, ru, it, el)
63+
("»", "«"), # reversed guillemets (de alt)
64+
("「", "」"), ("『", "』"), # CJK corner brackets (ja)
65+
("(", ")"), # fullwidth parentheses (CJK)
66+
})
5467

5568

5669
def _reject_bare_string_order(value: object) -> None:

tests/test_constants.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -545,11 +545,15 @@ def test_nickname_delimiters_default_builtins_resolve_live(self) -> None:
545545
# see test_overriding_builtin_regex_still_affects_nickname_parsing in
546546
# test_nicknames.py.
547547
c = Constants()
548-
self.assertEqual(dict(c.nickname_delimiters), {
549-
'quoted_word': 'quoted_word',
550-
'double_quotes': 'double_quotes',
551-
'parenthesis': 'parenthesis',
552-
})
548+
entries = dict(c.nickname_delimiters)
549+
# v1 trio still present, still stored name-as-value
550+
for name in ('quoted_word', 'double_quotes', 'parenthesis'):
551+
self.assertEqual(entries[name], name)
552+
# 2.0 adds the #273 typographic sentinels alongside them, same
553+
# name-as-value scheme (full list pinned in tests/v2/
554+
# test_config_shim.py against _SENTINEL_PAIRS)
555+
assert all(value == name for name, value in entries.items())
556+
assert 'smart_double_quotes' in entries
553557
self.assertEqual(dict(c.maiden_delimiters), {})
554558

555559
def test_extra_nickname_delimiters_removed(self) -> None:

tests/v2/cases.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,45 @@ def __post_init__(self) -> None:
126126
"one-liner replaces the bucket-move idiom; the v1 facade "
127127
"keeps v1's nickname-wins precedence via the shim's "
128128
"pre-subtraction (pinned in test_config_shim)"),
129+
# #273: typographic nickname delimiters ship as defaults -- one row
130+
# per new pair; expectations verified live 2026-07-19 before the
131+
# pairs were added to DEFAULT_NICKNAME_DELIMITERS.
132+
Case("smart_double_quotes_nickname", 'John “Jack” Kennedy',
133+
{"given": "John", "family": "Kennedy", "nickname": "Jack"},
134+
classification="feat(#273)"),
135+
Case("low_high_quotes_nickname", 'Hans „Hansi“ Müller',
136+
{"given": "Hans", "family": "Müller", "nickname": "Hansi"},
137+
classification="feat(#273)",
138+
notes="the closing '“' doubles as the English pair's opener; "
139+
"no spurious unbalanced-delimiter ambiguity (pinned in "
140+
"test_extract)"),
141+
Case("guillemets_nickname_inner_spaces", 'Jean « Petit » Dupont',
142+
{"given": "Jean", "family": "Dupont", "nickname": "Petit"},
143+
classification="feat(#273)",
144+
notes="French spacing: inner padding is trimmed from the "
145+
"extracted nickname"),
146+
Case("reversed_guillemets_nickname", 'Hans »Hansi« Müller',
147+
{"given": "Hans", "family": "Müller", "nickname": "Hansi"},
148+
classification="feat(#273)"),
149+
Case("swedish_right_quotes_nickname", 'Anna ”Ann” Larsson',
150+
{"given": "Anna", "family": "Larsson", "nickname": "Ann"},
151+
classification="feat(#273)"),
152+
Case("cjk_corner_bracket_nickname", '山田「タロ」太郎',
153+
{"given": "山田", "family": "太郎", "nickname": "タロ"},
154+
classification="feat(#273)",
155+
notes="extraction also splits the unspaced remainder -- the "
156+
"masked region acts as a token boundary"),
157+
Case("cjk_white_corner_bracket_nickname", '田中『ハナ』花子',
158+
{"given": "田中", "family": "花子", "nickname": "ハナ"},
159+
classification="feat(#273)"),
160+
Case("fullwidth_paren_nickname", 'John (Jack) Kennedy',
161+
{"given": "John", "family": "Kennedy", "nickname": "Jack"},
162+
classification="feat(#273)"),
163+
Case("curly_apostrophe_stays_literal", 'Sean O’Connor',
164+
{"given": "Sean", "family": "O’Connor"},
165+
notes="U+2019 is the typographic apostrophe; curly single "
166+
"quotes are deliberately NOT delimiters (#273 excludes "
167+
"them)"),
129168
Case("family_segment_trailing_suffix", "Smith Jr., John",
130169
{"given": "John", "family": "Smith", "suffix": "Jr."},
131170
notes="v1: the family part may have suffixes in it "

tests/v2/pipeline/test_extract.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ def test_unbalanced_delimiter_left_literal_with_ambiguity() -> None:
4343
assert out.ambiguities[0].kind is AmbiguityKind.UNBALANCED_DELIMITER
4444

4545

46+
def test_no_spurious_unbalanced_from_role_overlapping_pairs() -> None:
47+
# #273: '“' closes the German „…“ pair but OPENS the English “…”
48+
# pair, and '»' closes «…» but opens the reversed »…« pair. A
49+
# delimiter character consumed by another pair's successful
50+
# extraction is literal for every other pair -- it must not
51+
# surface as an unbalanced-delimiter ambiguity.
52+
policy = Policy(nickname_delimiters=frozenset(
53+
{("“", "”"), ("„", "“"), ("«", "»"), ("»", "«")}))
54+
for text, inner in (("Hans „Hansi“ Müller", "Hansi"),
55+
("Jean «Petit» Dupont", "Petit")):
56+
out = extract_delimited(_state(text, policy))
57+
assert [text[s.start:s.end] for _, s in out.extracted] == [inner]
58+
assert out.ambiguities == ()
59+
60+
61+
def test_genuine_unbalanced_still_flagged_alongside_overlapping_pairs() -> None:
62+
# the suppression must not swallow REAL unbalanced opens: here the
63+
# German open has no close anywhere, and no other pair extracts
64+
policy = Policy(nickname_delimiters=frozenset(
65+
{("“", "”"), ("„", "“")}))
66+
out = extract_delimited(_state("Hans „Hansi Müller", policy))
67+
assert out.extracted == ()
68+
assert [a.kind for a in out.ambiguities] == [
69+
AmbiguityKind.UNBALANCED_DELIMITER]
70+
71+
4672
def test_maiden_delimiters_route_to_maiden() -> None:
4773
policy = dataclasses.replace(
4874
Policy(),

0 commit comments

Comments
 (0)