Skip to content

Commit 290435e

Browse files
derek73claude
andcommitted
Fix regression: keep built-in nickname delimiters live, isolate extras
Multi-agent review of PR #190 caught a critical regression: snapshotting quoted_word/double_quotes/parenthesis into nickname_delimiters at Constants.__init__ time silently broke the pre-existing, documented customization path of overriding CONSTANTS.regexes.parenthesis (etc.) and re-parsing, since the snapshot never saw later changes to regexes. parse_nicknames() now reads the three built-ins live from self.C.regexes (restoring that override path) and additionally iterates a renamed, initially-empty extra_nickname_delimiters collection for new patterns, matching what issue #112 actually asked for (add, not replace). Also: added extra_nickname_delimiters to conftest.py's autouse snapshot/restore list (it was missing, so mutating the global CONSTANTS copy in a test would have leaked into later tests), added regression/ removal/pickle-roundtrip/suffix-interaction tests, and fixed the stale parse_nicknames() docstring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent fbab289 commit 290435e

6 files changed

Lines changed: 85 additions & 25 deletions

File tree

docs/customize.rst

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ remove punctuation to normalize them for comparison.
5656
Adding Custom Nickname Delimiters
5757
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5858

59-
:py:obj:`~nameparser.config.Constants.nickname_delimiters` is a named,
60-
appendable group of the delimiter patterns that
61-
:py:meth:`~nameparser.parser.HumanName.parse_nicknames` loops through
62-
(``quoted_word``, ``double_quotes`` and ``parenthesis`` by default). Add
63-
your own pattern under a new key to recognize additional delimiters, then
64-
re-run :py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it
65-
up:
59+
:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes three
60+
built-in delimiters -- ``quoted_word``, ``double_quotes`` and
61+
``parenthesis`` -- read from :py:attr:`~nameparser.config.Constants.regexes`,
62+
so overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as
63+
before. To recognize an *additional* delimiter without overriding one of the
64+
built-ins, add a pattern to
65+
:py:obj:`~nameparser.config.Constants.extra_nickname_delimiters` (empty by
66+
default) under any key, then re-run
67+
:py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it up:
6668

6769
.. doctest::
6870

@@ -71,7 +73,7 @@ up:
7173
>>> hn = HumanName("Benjamin {Ben} Franklin", constants=None)
7274
>>> hn.nickname
7375
''
74-
>>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
76+
>>> hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
7577
>>> hn.parse_full_name()
7678
>>> hn.nickname
7779
'Ben'

nameparser/config/__init__.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ class Constants:
263263
suffix_acronyms_ambiguous: SetManager
264264
capitalization_exceptions: TupleManager[str]
265265
regexes: RegexTupleManager
266-
nickname_delimiters: TupleManager[re.Pattern[str]]
266+
extra_nickname_delimiters: TupleManager[re.Pattern[str]]
267267
_pst: Set[str] | None
268268

269269
string_format = "{title} {first} {middle} {last} {suffix} ({nickname})"
@@ -415,15 +415,13 @@ def __init__(self,
415415
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
416416
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
417417
self.regexes = RegexTupleManager(regexes)
418-
# Named, appendable group of delimiter patterns that parse_nicknames()
419-
# iterates in order -- see nameparser.config.regexes for the defaults.
420-
# Add a pattern here (and re-parse) to recognize a new delimiter
421-
# without needing to override parse_nicknames() itself. See issue #112.
422-
self.nickname_delimiters = TupleManager({
423-
'quoted_word': self.regexes.quoted_word,
424-
'double_quotes': self.regexes.double_quotes,
425-
'parenthesis': self.regexes.parenthesis,
426-
})
418+
# Named, appendable group of *additional* delimiter patterns that
419+
# parse_nicknames() iterates after its three built-in delimiters
420+
# (quoted_word/double_quotes/parenthesis, read live from self.regexes
421+
# so overriding those keeps working as before). Empty by default; add
422+
# a pattern here (and re-parse) to recognize a new delimiter without
423+
# needing to override parse_nicknames() itself. See issue #112.
424+
self.extra_nickname_delimiters = TupleManager()
427425
self.patronymic_name_order = patronymic_name_order
428426

429427
def _invalidate_pst(self) -> None:

nameparser/parser.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -784,8 +784,12 @@ def parse_nicknames(self) -> None:
784784
white space to allow for quotes in names like O'Connor and Kawai'ae'a.
785785
Double quotes and parenthesis can span white space.
786786
787-
Loops through 3 :py:data:`~nameparser.config.regexes.REGEXES`;
788-
`quoted_word`, `double_quotes` and `parenthesis`.
787+
Loops through the built-in `quoted_word`, `double_quotes` and
788+
`parenthesis` patterns in :py:attr:`~nameparser.config.Constants.regexes`,
789+
followed by any patterns added to
790+
:py:attr:`~nameparser.config.Constants.extra_nickname_delimiters` --
791+
see the "Adding Custom Nickname Delimiters" section of the
792+
customization docs.
789793
"""
790794

791795
def handle_match(m: 're.Match[str]') -> str:
@@ -825,10 +829,18 @@ def handle_match(m: 're.Match[str]') -> str:
825829
# Same handle_match for every delimiter: suffix-shaped content
826830
# is rare in quotes but not impossible, and the logic is delimiter-
827831
# agnostic, so there's no reason to special-case parenthesis here.
828-
# Iterating self.C.nickname_delimiters (rather than a hardcoded
829-
# tuple) lets callers add new delimiter patterns at runtime -- see
830-
# issue #112.
831-
for _re in self.C.nickname_delimiters.values():
832+
# The three built-ins are read live from self.C.regexes (not copied),
833+
# so overriding e.g. self.C.regexes.parenthesis keeps working as
834+
# before; extra_nickname_delimiters is iterated afterward so callers
835+
# can add new delimiter patterns at runtime without needing to
836+
# override parse_nicknames() itself -- see issue #112.
837+
delimiters = (
838+
self.C.regexes.quoted_word,
839+
self.C.regexes.double_quotes,
840+
self.C.regexes.parenthesis,
841+
*self.C.extra_nickname_delimiters.values(),
842+
)
843+
for _re in delimiters:
832844
self._full_name = _re.sub(handle_match, self._full_name)
833845

834846
def squash_emoji(self) -> None:

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"first_name_prefixes",
3636
"capitalization_exceptions",
3737
"regexes",
38+
"extra_nickname_delimiters",
3839
)
3940

4041

tests/test_constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import copy
22
import pickle
3+
import re
34
import timeit
45

56
from nameparser import HumanName
@@ -60,6 +61,17 @@ def test_can_change_global_constants(self) -> None:
6061
# No manual cleanup needed: the autouse fixture in conftest.py snapshots
6162
# and restores the global CONSTANTS collections around every test.
6263

64+
def test_can_add_global_extra_nickname_delimiter(self) -> None:
65+
# https://github.com/derek73/python-nameparser/issues/112
66+
hn = HumanName("")
67+
hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
68+
hn2 = HumanName("Benjamin {Ben} Franklin")
69+
self.assertEqual(hn2.has_own_config, False)
70+
self.m(hn2.nickname, "Ben", hn2)
71+
# No manual cleanup needed: the autouse fixture in conftest.py snapshots
72+
# and restores the global CONSTANTS collections (including
73+
# extra_nickname_delimiters) around every test.
74+
6375
def test_remove_multiple_arguments(self) -> None:
6476
hn = HumanName("Ms Hon Solo", constants=None)
6577
hn.C.titles.remove('hon', 'ms')
@@ -129,6 +141,7 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None:
129141
c.titles.add('customtitle')
130142
c.prefixes.add('customprefix')
131143
c.titles.remove('hon')
144+
c.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
132145

133146
# Safe: round-tripping a Constants the test just built, not untrusted data.
134147
restored = pickle.loads(pickle.dumps(c))
@@ -142,6 +155,8 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None:
142155
# The collections must also keep their manager type, not just contents.
143156
self.assertEqual(type(restored.titles), SetManager)
144157
self.assertEqual(type(restored.prefixes), SetManager)
158+
self.assertIn('curly_braces', restored.extra_nickname_delimiters)
159+
self.assertEqual(type(restored.extra_nickname_delimiters), TupleManager)
145160

146161
def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None:
147162
"""An instance-level scalar override must survive a pickle round-trip."""

tests/test_nicknames.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,28 @@ def test_add_custom_nickname_delimiter(self) -> None:
2121
hn = HumanName("Benjamin {Ben} Franklin", constants=None)
2222
# curly braces aren't a recognized delimiter by default
2323
self.m(hn.nickname, "", hn)
24-
hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
24+
hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
25+
hn.parse_full_name()
26+
self.m(hn.first, "Benjamin", hn)
27+
self.m(hn.last, "Franklin", hn)
28+
self.m(hn.nickname, "Ben", hn)
29+
30+
def test_remove_custom_nickname_delimiter(self) -> None:
31+
hn = HumanName("Benjamin {Ben} Franklin", constants=None)
32+
hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
33+
hn.parse_full_name()
34+
self.m(hn.nickname, "Ben", hn)
35+
del hn.C.extra_nickname_delimiters['curly_braces']
36+
hn.parse_full_name()
37+
self.m(hn.nickname, "", hn)
38+
39+
def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None:
40+
# The pre-existing customization path (overriding self.C.regexes
41+
# directly, documented since before #112) must keep working now that
42+
# parse_nicknames() also consults extra_nickname_delimiters.
43+
hn = HumanName("Benjamin [Ben] Franklin", constants=None)
44+
self.m(hn.nickname, "", hn)
45+
hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]', re.U)
2546
hn.parse_full_name()
2647
self.m(hn.first, "Benjamin", hn)
2748
self.m(hn.last, "Franklin", hn)
@@ -155,6 +176,17 @@ def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None:
155176
self.m(hn.nickname, "JD", hn)
156177
self.m(hn.suffix, "", hn)
157178

179+
def test_ambiguous_suffix_acronym_in_extra_delimiter_stays_nickname(self) -> None:
180+
# Same suffix-vs-nickname disambiguation as above, but through a
181+
# custom delimiter added via extra_nickname_delimiters -- confirms
182+
# handle_match() is applied uniformly regardless of which delimiter
183+
# matched, not just the three built-ins.
184+
hn = HumanName("JEFFREY {JD} BRICKEN", constants=None)
185+
hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
186+
hn.parse_full_name()
187+
self.m(hn.nickname, "JD", hn)
188+
self.m(hn.suffix, "", hn)
189+
158190

159191
# class MaidenNameTestCase(HumanNameTestBase):
160192
#

0 commit comments

Comments
 (0)