Skip to content

Commit 305ab0d

Browse files
derek73claude
andcommitted
fix: stop RegexTupleManager answering dunder probes with a regex
RegexTupleManager.__getattr__ returned the EMPTY_REGEX default for *any* unknown attribute, including the dunder names Python's machinery probes for. copy.deepcopy does getattr(obj, "__deepcopy__", None) to detect a custom deep-copy hook; it received EMPTY_REGEX (a truthy re.Pattern) instead of None, then tried to call it -> "re.Pattern object is not callable". The same trap applied to copy.copy, inspect.unwrap, etc. Reject dunder-shaped names with AttributeError so those probes see the attribute as absent. Non-dunder unknown keys still get the EMPTY_REGEX default, preserving existing behavior. With deepcopy now working for the config managers, drop the _clone_config_collection workaround in the test conftest (and its now-stale comment) in favor of copy.deepcopy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8fb251c commit 305ab0d

3 files changed

Lines changed: 41 additions & 18 deletions

File tree

nameparser/config/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ def __reduce__(self) -> tuple[type, tuple[()], Mapping[str, T]]:
147147

148148
class RegexTupleManager(TupleManager[re.Pattern[str]]):
149149
def __getattr__(self, attr: str) -> re.Pattern[str]:
150+
# Dunder names are Python's protocol probes (copy.deepcopy looks up
151+
# __deepcopy__, inspect.unwrap looks up __wrapped__, ...), never regex
152+
# keys. Report them as genuinely absent; otherwise the EMPTY_REGEX
153+
# default is mistaken for a real protocol hook — e.g. copy.deepcopy
154+
# tries to call the returned re.Pattern and raises TypeError.
155+
if attr.startswith("__") and attr.endswith("__"):
156+
raise AttributeError(attr)
150157
return self.get(attr, EMPTY_REGEX)
151158

152159

tests/conftest.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
import copy
12
from collections.abc import Iterator
23

34
import pytest
45

5-
from nameparser.config import CONSTANTS, SetManager, TupleManager
6-
7-
ConfigCollection = SetManager | TupleManager
6+
from nameparser.config import CONSTANTS
87

98
# Scalar (non-collection) config attributes that individual tests mutate on the
109
# global CONSTANTS singleton. Several tests change these without restoring them;
@@ -38,20 +37,6 @@
3837
)
3938

4039

41-
def _clone_config_collection(value: ConfigCollection) -> ConfigCollection:
42-
"""Return an independent copy of a config collection manager.
43-
44-
``copy.deepcopy`` is not used because ``RegexTupleManager`` carries compiled
45-
patterns that its ``__reduce__`` cannot round-trip. Rebuilding from the
46-
manager's own contents copies the container while sharing the (immutable)
47-
elements, which is all the snapshot needs.
48-
"""
49-
if isinstance(value, SetManager):
50-
return SetManager(set(value))
51-
# TupleManager / RegexTupleManager are dict subclasses.
52-
return type(value)(dict(value))
53-
54-
5540
@pytest.fixture(autouse=True, params=['', None], ids=['default', 'none'])
5641
def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | None]:
5742
"""Run every test under both empty_attribute_default settings, isolating global config.
@@ -66,7 +51,7 @@ def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | No
6651
"""
6752
scalar_snapshot = {attr: getattr(CONSTANTS, attr) for attr in _SCALAR_CONFIG_ATTRS}
6853
collection_snapshot = {
69-
attr: _clone_config_collection(getattr(CONSTANTS, attr))
54+
attr: copy.deepcopy(getattr(CONSTANTS, attr))
7055
for attr in _COLLECTION_CONFIG_ATTRS
7156
}
7257
CONSTANTS.empty_attribute_default = request.param

tests/test_constants.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import pickle
23

34
from nameparser import HumanName
@@ -159,6 +160,36 @@ def test_pickle_roundtrip_preserves_regex_manager_subclass(self) -> None:
159160
self.assertEqual(type(restored.regexes), RegexTupleManager)
160161
self.assertEqual(restored.regexes.does_not_exist, EMPTY_REGEX)
161162

163+
def test_regexes_deepcopy_roundtrip(self) -> None:
164+
"""copy.deepcopy of a RegexTupleManager must round-trip.
165+
166+
__getattr__ answered every unknown name with the EMPTY_REGEX default,
167+
including the __deepcopy__ probe copy.deepcopy issues. copy then
168+
mistook that re.Pattern for a deep-copy hook and tried to call it.
169+
"""
170+
c = Constants()
171+
172+
dup = copy.deepcopy(c.regexes)
173+
174+
self.assertEqual(type(dup), RegexTupleManager)
175+
self.assertEqual(dict(dup), dict(c.regexes))
176+
# The EMPTY_REGEX default still applies to genuinely unknown keys.
177+
self.assertEqual(dup.does_not_exist, EMPTY_REGEX)
178+
179+
def test_regextuplemanager_ignores_dunder_lookups(self) -> None:
180+
"""Unknown dunder names report as absent, not as the EMPTY_REGEX default.
181+
182+
Dunder names are Python's protocol probes (copy.deepcopy looks up
183+
__deepcopy__, inspect.unwrap looks up __wrapped__, ...), never config
184+
keys. Answering them with a regex breaks that machinery.
185+
"""
186+
c = Constants()
187+
sentinel = object()
188+
189+
self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel)
190+
# A normal (non-dunder) unknown key still yields the EMPTY_REGEX default.
191+
self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX)
192+
162193
def test_unpickle_legacy_state_with_property_key(self) -> None:
163194
"""Pickles written by older versions must still load.
164195

0 commit comments

Comments
 (0)