Skip to content

Commit 2e55728

Browse files
derek73claude
andauthored
Allow adding nickname delimiter patterns at runtime (#190)
* Allow adding nickname delimiter patterns at runtime (#112) parse_nicknames() looped over a hardcoded tuple of three named regexes, so overriding an existing pattern in CONSTANTS.regexes and re-parsing worked, but adding a brand new delimiter had no effect since nothing iterated a variable set of patterns. Constants now exposes nickname_delimiters, a named TupleManager of delimiter patterns (seeded with the existing quoted_word/double_quotes/ parenthesis regexes) that parse_nicknames() iterates. Adding an entry and calling parse_full_name() again picks it up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * 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> * Document conftest registration for new Constants collections Extension Patterns had guidance for adding a scalar Constants attribute but nothing for a mutable/collection one, which is how extra_nickname_delimiters almost shipped without being added to conftest.py's autouse snapshot/restore list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Close post-review test gaps for extra_nickname_delimiters Multi-agent review of PR #190 flagged two coverage gaps: no test for registering multiple extra delimiters together, and no dedicated deepcopy round-trip test for extra_nickname_delimiters itself (only incidental coverage via conftest's autouse snapshot/restore). Also add assertIsNone/assertIsNotNone to HumanNameTestBase, needed by the new deepcopy test, and a note in AGENTS.md to add a deepcopy test whenever a new mutable Constants collection is introduced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bd212e5 commit 2e55728

8 files changed

Lines changed: 153 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co
110110
3. Add `self.x = x if x is not None else self.C.x` in body — use `is not None`, not `or`, to allow falsy values like `""`
111111
4. conftest auto-restores scalar CONSTANTS between tests, but tests that *set* CONSTANTS mid-run still need their own try/finally
112112

113+
**Adding a new mutable/collection `Constants` attribute** (a `SetManager`/`TupleManager`-backed group, e.g. `extra_nickname_delimiters`): add it to `_COLLECTION_CONFIG_ATTRS` in `tests/conftest.py`, or tests that mutate the global `CONSTANTS` copy will leak state into later tests. Contents must be deep-copyable (the snapshot uses `copy.deepcopy`) — already true for the existing manager types.
114+
115+
Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_deepcopy_roundtrip`/`test_extra_nickname_delimiters_deepcopy_roundtrip` in `tests/test_constants.py`), not just reliance on conftest's autouse snapshot/restore exercising it incidentally. `TupleManager`/`RegexTupleManager.__getattr__` answer *any* unknown attribute lookup — including dunder probes like `__deepcopy__` — so a new manager subtype or a `__getattr__` tweak can silently break `copy.deepcopy` (this bit `RegexTupleManager` before the dunder-lookup guard was added). A direct test on the new attribute's own manager instance catches that where the conftest fixture, which never asserts on the copy, would not.
116+
113117
**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc``PREFIXES``TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu``PREFIXES``first_name_prefixes` (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `first_name_prefixes`).
114118

115119
**Adding a flag-gated post-parse transform** (reorder/adjust, e.g. `patronymic_name_order`) — add a `Constants` boolean (default `False`), implement a `handle_*()` method, and call it in `post_process()` after `handle_firstnames()` and before `handle_capitalization()`, gated on the flag. Default-off keeps existing parses byte-for-byte unchanged. (#85; extension point for #185 Turkic.)

docs/customize.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ Each set of constants comes with :py:func:`~nameparser.config.SetManager.add` an
5353
the constants for your project. These methods automatically lower case and
5454
remove punctuation to normalize them for comparison.
5555

56+
Adding Custom Nickname Delimiters
57+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
58+
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:
68+
69+
.. doctest::
70+
71+
>>> import re
72+
>>> from nameparser import HumanName
73+
>>> hn = HumanName("Benjamin {Ben} Franklin", constants=None)
74+
>>> hn.nickname
75+
''
76+
>>> hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
77+
>>> hn.parse_full_name()
78+
>>> hn.nickname
79+
'Ben'
80+
5681
Other editable attributes
5782
~~~~~~~~~~~~~~~~~~~~~~~~~~
5883

nameparser/config/__init__.py

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

268269
string_format = "{title} {first} {middle} {last} {suffix} ({nickname})"
@@ -414,6 +415,13 @@ def __init__(self,
414415
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
415416
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
416417
self.regexes = RegexTupleManager(regexes)
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()
417425
self.patronymic_name_order = patronymic_name_order
418426

419427
def _invalidate_pst(self) -> None:

nameparser/parser.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -784,14 +784,14 @@ 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

791-
re_quoted_word = self.C.regexes.quoted_word
792-
re_double_quotes = self.C.regexes.double_quotes
793-
re_parenthesis = self.C.regexes.parenthesis
794-
795795
def handle_match(m: 're.Match[str]') -> str:
796796
# Fall back to the whole match when the regex has no capturing
797797
# group (e.g. a custom override regex without one, like
@@ -826,10 +826,21 @@ def handle_match(m: 're.Match[str]') -> str:
826826
self.nickname_list.append(content)
827827
return ''
828828

829-
# Same handle_match for all three delimiters: suffix-shaped content
829+
# Same handle_match for every delimiter: suffix-shaped content
830830
# is rare in quotes but not impossible, and the logic is delimiter-
831831
# agnostic, so there's no reason to special-case parenthesis here.
832-
for _re in (re_quoted_word, re_double_quotes, re_parenthesis):
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:
833844
self._full_name = _re.sub(handle_match, self._full_name)
834845

835846
def squash_emoji(self) -> None:

tests/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,9 @@ def assertIs(self, first: object, second: object, msg: object = None) -> None:
5050

5151
def assertIsNot(self, first: object, second: object, msg: object = None) -> None:
5252
assert first is not second, msg or f"{first!r} is {second!r}"
53+
54+
def assertIsNone(self, expr: object, msg: object = None) -> None:
55+
assert expr is None, msg or f"{expr!r} is not None"
56+
57+
def assertIsNotNone(self, expr: object, msg: object = None) -> None:
58+
assert expr is not None, msg or "unexpectedly 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: 34 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."""
@@ -185,6 +200,25 @@ def test_regexes_deepcopy_roundtrip(self) -> None:
185200
# The EMPTY_REGEX default still applies to genuinely unknown keys.
186201
self.assertEqual(dup.does_not_exist, EMPTY_REGEX)
187202

203+
def test_extra_nickname_delimiters_deepcopy_roundtrip(self) -> None:
204+
"""copy.deepcopy of extra_nickname_delimiters must round-trip.
205+
206+
Mirrors test_regexes_deepcopy_roundtrip: extra_nickname_delimiters is a
207+
plain TupleManager (not RegexTupleManager), but shares the same
208+
__getattr__/__reduce__ machinery, so it's exercised here directly
209+
rather than only incidentally via conftest's autouse snapshot/restore.
210+
"""
211+
c = Constants()
212+
c.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
213+
214+
dup = copy.deepcopy(c.extra_nickname_delimiters)
215+
216+
self.assertEqual(type(dup), TupleManager)
217+
self.assertEqual(dict(dup), dict(c.extra_nickname_delimiters))
218+
# Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are None.
219+
self.assertIsNone(dup.does_not_exist)
220+
self.assertIsNotNone(dup.curly_braces)
221+
188222
def test_regextuplemanager_ignores_dunder_lookups(self) -> None:
189223
"""Unknown dunder names report as absent, not as the EMPTY_REGEX default.
190224

tests/test_nicknames.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
import pytest
24

35
from nameparser import HumanName
@@ -14,6 +16,49 @@ def test_nickname_in_parenthesis(self) -> None:
1416
self.m(hn.last, "Franklin", hn)
1517
self.m(hn.nickname, "Ben", hn)
1618

19+
# https://github.com/derek73/python-nameparser/issues/112
20+
def test_add_custom_nickname_delimiter(self) -> None:
21+
hn = HumanName("Benjamin {Ben} Franklin", constants=None)
22+
# curly braces aren't a recognized delimiter by default
23+
self.m(hn.nickname, "", hn)
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_multiple_custom_nickname_delimiters_together(self) -> None:
40+
# Two extras registered at once must both be recognized in a single
41+
# parse, independent of insertion order.
42+
hn = HumanName("Benjamin {Ben} <Benny> Franklin", constants=None)
43+
hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
44+
hn.C.extra_nickname_delimiters['angle_brackets'] = re.compile(r'<(.*?)>', re.U)
45+
hn.parse_full_name()
46+
self.m(hn.first, "Benjamin", hn)
47+
self.m(hn.last, "Franklin", hn)
48+
self.m(hn.nickname, "Ben Benny", hn)
49+
50+
def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None:
51+
# The pre-existing customization path (overriding self.C.regexes
52+
# directly, documented since before #112) must keep working now that
53+
# parse_nicknames() also consults extra_nickname_delimiters.
54+
hn = HumanName("Benjamin [Ben] Franklin", constants=None)
55+
self.m(hn.nickname, "", hn)
56+
hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]', re.U)
57+
hn.parse_full_name()
58+
self.m(hn.first, "Benjamin", hn)
59+
self.m(hn.last, "Franklin", hn)
60+
self.m(hn.nickname, "Ben", hn)
61+
1762
def test_two_word_nickname_in_parenthesis(self) -> None:
1863
hn = HumanName("Benjamin (Big Ben) Franklin")
1964
self.m(hn.first, "Benjamin", hn)
@@ -142,6 +187,17 @@ def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None:
142187
self.m(hn.nickname, "JD", hn)
143188
self.m(hn.suffix, "", hn)
144189

190+
def test_ambiguous_suffix_acronym_in_extra_delimiter_stays_nickname(self) -> None:
191+
# Same suffix-vs-nickname disambiguation as above, but through a
192+
# custom delimiter added via extra_nickname_delimiters -- confirms
193+
# handle_match() is applied uniformly regardless of which delimiter
194+
# matched, not just the three built-ins.
195+
hn = HumanName("JEFFREY {JD} BRICKEN", constants=None)
196+
hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
197+
hn.parse_full_name()
198+
self.m(hn.nickname, "JD", hn)
199+
self.m(hn.suffix, "", hn)
200+
145201

146202
# class MaidenNameTestCase(HumanNameTestBase):
147203
#

0 commit comments

Comments
 (0)