From fbab28969a6bc38def378b73ae6e1063ec416adc Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 01:06:16 -0700 Subject: [PATCH 1/4] 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 --- docs/customize.rst | 23 +++++++++++++++++++++++ nameparser/config/__init__.py | 10 ++++++++++ nameparser/parser.py | 11 +++++------ tests/test_nicknames.py | 13 +++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index 0ee22c6..fb74011 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -53,6 +53,29 @@ Each set of constants comes with :py:func:`~nameparser.config.SetManager.add` an the constants for your project. These methods automatically lower case and remove punctuation to normalize them for comparison. +Adding Custom Nickname Delimiters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:py:obj:`~nameparser.config.Constants.nickname_delimiters` is a named, +appendable group of the delimiter patterns that +:py:meth:`~nameparser.parser.HumanName.parse_nicknames` loops through +(``quoted_word``, ``double_quotes`` and ``parenthesis`` by default). Add +your own pattern under a new key to recognize additional delimiters, then +re-run :py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it +up: + +.. doctest:: + + >>> import re + >>> from nameparser import HumanName + >>> hn = HumanName("Benjamin {Ben} Franklin", constants=None) + >>> hn.nickname + '' + >>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + >>> hn.parse_full_name() + >>> hn.nickname + 'Ben' + Other editable attributes ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 79d4f8c..34056d5 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -263,6 +263,7 @@ class Constants: suffix_acronyms_ambiguous: SetManager capitalization_exceptions: TupleManager[str] regexes: RegexTupleManager + nickname_delimiters: TupleManager[re.Pattern[str]] _pst: Set[str] | None string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" @@ -414,6 +415,15 @@ def __init__(self, self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous) self.capitalization_exceptions = TupleManager(capitalization_exceptions) self.regexes = RegexTupleManager(regexes) + # Named, appendable group of delimiter patterns that parse_nicknames() + # iterates in order -- see nameparser.config.regexes for the defaults. + # Add a pattern here (and re-parse) to recognize a new delimiter + # without needing to override parse_nicknames() itself. See issue #112. + self.nickname_delimiters = TupleManager({ + 'quoted_word': self.regexes.quoted_word, + 'double_quotes': self.regexes.double_quotes, + 'parenthesis': self.regexes.parenthesis, + }) self.patronymic_name_order = patronymic_name_order def _invalidate_pst(self) -> None: diff --git a/nameparser/parser.py b/nameparser/parser.py index bdd00cf..6201c38 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -788,10 +788,6 @@ def parse_nicknames(self) -> None: `quoted_word`, `double_quotes` and `parenthesis`. """ - re_quoted_word = self.C.regexes.quoted_word - re_double_quotes = self.C.regexes.double_quotes - re_parenthesis = self.C.regexes.parenthesis - def handle_match(m: 're.Match[str]') -> str: # Fall back to the whole match when the regex has no capturing # group (e.g. a custom override regex without one, like @@ -826,10 +822,13 @@ def handle_match(m: 're.Match[str]') -> str: self.nickname_list.append(content) return '' - # Same handle_match for all three delimiters: suffix-shaped content + # Same handle_match for every delimiter: suffix-shaped content # is rare in quotes but not impossible, and the logic is delimiter- # agnostic, so there's no reason to special-case parenthesis here. - for _re in (re_quoted_word, re_double_quotes, re_parenthesis): + # Iterating self.C.nickname_delimiters (rather than a hardcoded + # tuple) lets callers add new delimiter patterns at runtime -- see + # issue #112. + for _re in self.C.nickname_delimiters.values(): self._full_name = _re.sub(handle_match, self._full_name) def squash_emoji(self) -> None: diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index fa79e45..6a4a6b4 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -1,3 +1,5 @@ +import re + import pytest from nameparser import HumanName @@ -14,6 +16,17 @@ def test_nickname_in_parenthesis(self) -> None: self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) + # https://github.com/derek73/python-nameparser/issues/112 + def test_add_custom_nickname_delimiter(self) -> None: + hn = HumanName("Benjamin {Ben} Franklin", constants=None) + # curly braces aren't a recognized delimiter by default + self.m(hn.nickname, "", hn) + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn.parse_full_name() + self.m(hn.first, "Benjamin", hn) + self.m(hn.last, "Franklin", hn) + self.m(hn.nickname, "Ben", hn) + def test_two_word_nickname_in_parenthesis(self) -> None: hn = HumanName("Benjamin (Big Ben) Franklin") self.m(hn.first, "Benjamin", hn) From 290435e5e33b18721b1b23e35a1e50f3c5900733 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 01:14:57 -0700 Subject: [PATCH 2/4] 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 --- docs/customize.rst | 18 ++++++++++-------- nameparser/config/__init__.py | 18 ++++++++---------- nameparser/parser.py | 24 ++++++++++++++++++------ tests/conftest.py | 1 + tests/test_constants.py | 15 +++++++++++++++ tests/test_nicknames.py | 34 +++++++++++++++++++++++++++++++++- 6 files changed, 85 insertions(+), 25 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index fb74011..03c6dc1 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -56,13 +56,15 @@ remove punctuation to normalize them for comparison. Adding Custom Nickname Delimiters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -:py:obj:`~nameparser.config.Constants.nickname_delimiters` is a named, -appendable group of the delimiter patterns that -:py:meth:`~nameparser.parser.HumanName.parse_nicknames` loops through -(``quoted_word``, ``double_quotes`` and ``parenthesis`` by default). Add -your own pattern under a new key to recognize additional delimiters, then -re-run :py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it -up: +:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes three +built-in delimiters -- ``quoted_word``, ``double_quotes`` and +``parenthesis`` -- read from :py:attr:`~nameparser.config.Constants.regexes`, +so overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as +before. To recognize an *additional* delimiter without overriding one of the +built-ins, add a pattern to +:py:obj:`~nameparser.config.Constants.extra_nickname_delimiters` (empty by +default) under any key, then re-run +:py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it up: .. doctest:: @@ -71,7 +73,7 @@ up: >>> hn = HumanName("Benjamin {Ben} Franklin", constants=None) >>> hn.nickname '' - >>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + >>> hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) >>> hn.parse_full_name() >>> hn.nickname 'Ben' diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 34056d5..e0374ce 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -263,7 +263,7 @@ class Constants: suffix_acronyms_ambiguous: SetManager capitalization_exceptions: TupleManager[str] regexes: RegexTupleManager - nickname_delimiters: TupleManager[re.Pattern[str]] + extra_nickname_delimiters: TupleManager[re.Pattern[str]] _pst: Set[str] | None string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" @@ -415,15 +415,13 @@ def __init__(self, self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous) self.capitalization_exceptions = TupleManager(capitalization_exceptions) self.regexes = RegexTupleManager(regexes) - # Named, appendable group of delimiter patterns that parse_nicknames() - # iterates in order -- see nameparser.config.regexes for the defaults. - # Add a pattern here (and re-parse) to recognize a new delimiter - # without needing to override parse_nicknames() itself. See issue #112. - self.nickname_delimiters = TupleManager({ - 'quoted_word': self.regexes.quoted_word, - 'double_quotes': self.regexes.double_quotes, - 'parenthesis': self.regexes.parenthesis, - }) + # Named, appendable group of *additional* delimiter patterns that + # parse_nicknames() iterates after its three built-in delimiters + # (quoted_word/double_quotes/parenthesis, read live from self.regexes + # so overriding those keeps working as before). Empty by default; add + # a pattern here (and re-parse) to recognize a new delimiter without + # needing to override parse_nicknames() itself. See issue #112. + self.extra_nickname_delimiters = TupleManager() self.patronymic_name_order = patronymic_name_order def _invalidate_pst(self) -> None: diff --git a/nameparser/parser.py b/nameparser/parser.py index 6201c38..f36e9f1 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -784,8 +784,12 @@ def parse_nicknames(self) -> None: white space to allow for quotes in names like O'Connor and Kawai'ae'a. Double quotes and parenthesis can span white space. - Loops through 3 :py:data:`~nameparser.config.regexes.REGEXES`; - `quoted_word`, `double_quotes` and `parenthesis`. + Loops through the built-in `quoted_word`, `double_quotes` and + `parenthesis` patterns in :py:attr:`~nameparser.config.Constants.regexes`, + followed by any patterns added to + :py:attr:`~nameparser.config.Constants.extra_nickname_delimiters` -- + see the "Adding Custom Nickname Delimiters" section of the + customization docs. """ def handle_match(m: 're.Match[str]') -> str: @@ -825,10 +829,18 @@ def handle_match(m: 're.Match[str]') -> str: # Same handle_match for every delimiter: suffix-shaped content # is rare in quotes but not impossible, and the logic is delimiter- # agnostic, so there's no reason to special-case parenthesis here. - # Iterating self.C.nickname_delimiters (rather than a hardcoded - # tuple) lets callers add new delimiter patterns at runtime -- see - # issue #112. - for _re in self.C.nickname_delimiters.values(): + # The three built-ins are read live from self.C.regexes (not copied), + # so overriding e.g. self.C.regexes.parenthesis keeps working as + # before; extra_nickname_delimiters is iterated afterward so callers + # can add new delimiter patterns at runtime without needing to + # override parse_nicknames() itself -- see issue #112. + delimiters = ( + self.C.regexes.quoted_word, + self.C.regexes.double_quotes, + self.C.regexes.parenthesis, + *self.C.extra_nickname_delimiters.values(), + ) + for _re in delimiters: self._full_name = _re.sub(handle_match, self._full_name) def squash_emoji(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 093e431..c9d5493 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ "first_name_prefixes", "capitalization_exceptions", "regexes", + "extra_nickname_delimiters", ) diff --git a/tests/test_constants.py b/tests/test_constants.py index 281bdf3..5491bd0 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,5 +1,6 @@ import copy import pickle +import re import timeit from nameparser import HumanName @@ -60,6 +61,17 @@ def test_can_change_global_constants(self) -> None: # No manual cleanup needed: the autouse fixture in conftest.py snapshots # and restores the global CONSTANTS collections around every test. + def test_can_add_global_extra_nickname_delimiter(self) -> None: + # https://github.com/derek73/python-nameparser/issues/112 + hn = HumanName("") + hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn2 = HumanName("Benjamin {Ben} Franklin") + self.assertEqual(hn2.has_own_config, False) + self.m(hn2.nickname, "Ben", hn2) + # No manual cleanup needed: the autouse fixture in conftest.py snapshots + # and restores the global CONSTANTS collections (including + # extra_nickname_delimiters) around every test. + def test_remove_multiple_arguments(self) -> None: hn = HumanName("Ms Hon Solo", constants=None) hn.C.titles.remove('hon', 'ms') @@ -129,6 +141,7 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: c.titles.add('customtitle') c.prefixes.add('customprefix') c.titles.remove('hon') + c.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) @@ -142,6 +155,8 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: # The collections must also keep their manager type, not just contents. self.assertEqual(type(restored.titles), SetManager) self.assertEqual(type(restored.prefixes), SetManager) + self.assertIn('curly_braces', restored.extra_nickname_delimiters) + self.assertEqual(type(restored.extra_nickname_delimiters), TupleManager) def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None: """An instance-level scalar override must survive a pickle round-trip.""" diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index 6a4a6b4..c800570 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -21,7 +21,28 @@ def test_add_custom_nickname_delimiter(self) -> None: hn = HumanName("Benjamin {Ben} Franklin", constants=None) # curly braces aren't a recognized delimiter by default self.m(hn.nickname, "", hn) - hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn.parse_full_name() + self.m(hn.first, "Benjamin", hn) + self.m(hn.last, "Franklin", hn) + self.m(hn.nickname, "Ben", hn) + + def test_remove_custom_nickname_delimiter(self) -> None: + hn = HumanName("Benjamin {Ben} Franklin", constants=None) + hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn.parse_full_name() + self.m(hn.nickname, "Ben", hn) + del hn.C.extra_nickname_delimiters['curly_braces'] + hn.parse_full_name() + self.m(hn.nickname, "", hn) + + def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None: + # The pre-existing customization path (overriding self.C.regexes + # directly, documented since before #112) must keep working now that + # parse_nicknames() also consults extra_nickname_delimiters. + hn = HumanName("Benjamin [Ben] Franklin", constants=None) + self.m(hn.nickname, "", hn) + hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]', re.U) hn.parse_full_name() self.m(hn.first, "Benjamin", hn) self.m(hn.last, "Franklin", hn) @@ -155,6 +176,17 @@ def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None: self.m(hn.nickname, "JD", hn) self.m(hn.suffix, "", hn) + def test_ambiguous_suffix_acronym_in_extra_delimiter_stays_nickname(self) -> None: + # Same suffix-vs-nickname disambiguation as above, but through a + # custom delimiter added via extra_nickname_delimiters -- confirms + # handle_match() is applied uniformly regardless of which delimiter + # matched, not just the three built-ins. + hn = HumanName("JEFFREY {JD} BRICKEN", constants=None) + hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn.parse_full_name() + self.m(hn.nickname, "JD", hn) + self.m(hn.suffix, "", hn) + # class MaidenNameTestCase(HumanNameTestBase): # From d5c7fa59d86757e4e1c1fca8e28c2c85ed9e56e1 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 01:32:21 -0700 Subject: [PATCH 3/4] 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 --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4a29bfc..a122e70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co 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 `""` 4. conftest auto-restores scalar CONSTANTS between tests, but tests that *set* CONSTANTS mid-run still need their own try/finally +**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. + **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`). **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.) From 5bb26c7322f62e2e7e76c32f4efc7c99c5709d9d Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 01:54:50 -0700 Subject: [PATCH 4/4] 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 --- AGENTS.md | 2 ++ tests/base.py | 6 ++++++ tests/test_constants.py | 19 +++++++++++++++++++ tests/test_nicknames.py | 11 +++++++++++ 4 files changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a122e70..24a777e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,8 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co **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. +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. + **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`). **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.) diff --git a/tests/base.py b/tests/base.py index 3c2f454..f130857 100644 --- a/tests/base.py +++ b/tests/base.py @@ -50,3 +50,9 @@ def assertIs(self, first: object, second: object, msg: object = None) -> None: def assertIsNot(self, first: object, second: object, msg: object = None) -> None: assert first is not second, msg or f"{first!r} is {second!r}" + + def assertIsNone(self, expr: object, msg: object = None) -> None: + assert expr is None, msg or f"{expr!r} is not None" + + def assertIsNotNone(self, expr: object, msg: object = None) -> None: + assert expr is not None, msg or "unexpectedly None" diff --git a/tests/test_constants.py b/tests/test_constants.py index 5491bd0..369e5c8 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -200,6 +200,25 @@ def test_regexes_deepcopy_roundtrip(self) -> None: # The EMPTY_REGEX default still applies to genuinely unknown keys. self.assertEqual(dup.does_not_exist, EMPTY_REGEX) + def test_extra_nickname_delimiters_deepcopy_roundtrip(self) -> None: + """copy.deepcopy of extra_nickname_delimiters must round-trip. + + Mirrors test_regexes_deepcopy_roundtrip: extra_nickname_delimiters is a + plain TupleManager (not RegexTupleManager), but shares the same + __getattr__/__reduce__ machinery, so it's exercised here directly + rather than only incidentally via conftest's autouse snapshot/restore. + """ + c = Constants() + c.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + + dup = copy.deepcopy(c.extra_nickname_delimiters) + + self.assertEqual(type(dup), TupleManager) + self.assertEqual(dict(dup), dict(c.extra_nickname_delimiters)) + # Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are None. + self.assertIsNone(dup.does_not_exist) + self.assertIsNotNone(dup.curly_braces) + def test_regextuplemanager_ignores_dunder_lookups(self) -> None: """Unknown dunder names report as absent, not as the EMPTY_REGEX default. diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index c800570..4bd14b0 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -36,6 +36,17 @@ def test_remove_custom_nickname_delimiter(self) -> None: hn.parse_full_name() self.m(hn.nickname, "", hn) + def test_multiple_custom_nickname_delimiters_together(self) -> None: + # Two extras registered at once must both be recognized in a single + # parse, independent of insertion order. + hn = HumanName("Benjamin {Ben} Franklin", constants=None) + hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + hn.C.extra_nickname_delimiters['angle_brackets'] = re.compile(r'<(.*?)>', re.U) + hn.parse_full_name() + self.m(hn.first, "Benjamin", hn) + self.m(hn.last, "Franklin", hn) + self.m(hn.nickname, "Ben Benny", hn) + def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None: # The pre-existing customization path (overriding self.C.regexes # directly, documented since before #112) must keep working now that