Skip to content

Commit 44ed40c

Browse files
derek73claude
andcommitted
fix: guard base TupleManager dunder probes; test HumanName deepcopy
Two follow-ups to the copy/pickle round-trip fixes: - TupleManager.__getattr__ returned None for any missing attribute, so hasattr(tm, '__anydunder__') was always True. copy.deepcopy tolerated this (it reads None as "absent"), but hasattr-then-call probes would break. Apply the same dunder guard as RegexTupleManager so the base is consistent and hasattr is honest. - Add HumanName deepcopy tests. HumanName has no custom copy hooks, so copy.deepcopy(name) recurses into .C.regexes and previously raised TypeError for every name; the RegexTupleManager dunder guard fixed it, and these tests lock that in (round-trip equality + instance-config isolation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 305ab0d commit 44ed40c

3 files changed

Lines changed: 51 additions & 1 deletion

File tree

nameparser/config/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ class TupleManager(dict[str, T]):
126126
'''
127127

128128
def __getattr__(self, attr: str) -> T | None:
129+
# Dunder names are Python's protocol probes (copy looks up __deepcopy__,
130+
# inspect.unwrap looks up __wrapped__, ...), never config keys. Report
131+
# them as genuinely absent so hasattr() is honest and those probes work;
132+
# otherwise the dict default is mistaken for a real protocol hook. See
133+
# RegexTupleManager.__getattr__ for the concrete failure this prevents.
134+
if attr.startswith("__") and attr.endswith("__"):
135+
raise AttributeError(attr)
129136
return self.get(attr)
130137

131138
__setattr__ = dict.__setitem__

tests/test_constants.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import pickle
33

44
from nameparser import HumanName
5-
from nameparser.config import Constants, RegexTupleManager, SetManager
5+
from nameparser.config import Constants, RegexTupleManager, SetManager, TupleManager
66
from nameparser.config.regexes import EMPTY_REGEX
77

88
from tests.base import HumanNameTestBase
@@ -190,6 +190,23 @@ def test_regextuplemanager_ignores_dunder_lookups(self) -> None:
190190
# A normal (non-dunder) unknown key still yields the EMPTY_REGEX default.
191191
self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX)
192192

193+
def test_tuplemanager_ignores_dunder_lookups(self) -> None:
194+
"""Base TupleManager must report unknown dunder names as absent too.
195+
196+
It returned None for any missing attribute, so `hasattr(tm, '__x__')`
197+
was always True — a landmine for any probe that does hasattr-then-call.
198+
Guarding dunders keeps the base consistent with RegexTupleManager.
199+
"""
200+
c = Constants()
201+
tm = c.capitalization_exceptions # a plain TupleManager
202+
sentinel = object()
203+
204+
self.assertEqual(type(tm), TupleManager)
205+
self.assertFalse(hasattr(tm, '__deepcopy__'))
206+
self.assertEqual(getattr(tm, '__wrapped__', sentinel), sentinel)
207+
# A normal (non-dunder) unknown key still returns the None default.
208+
self.assertEqual(tm.unknown_key, None)
209+
193210
def test_unpickle_legacy_state_with_property_key(self) -> None:
194211
"""Pickles written by older versions must still load.
195212

tests/test_python_api.py

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

@@ -66,6 +67,31 @@ def test_name_instance_pickle_preserves_instance_config(self) -> None:
6667
restored.full_name = "Chancellor Jane Smith"
6768
self.assertEqual(restored.title, "Chancellor")
6869

70+
def test_name_instance_deepcopy(self) -> None:
71+
"""copy.deepcopy of a HumanName must round-trip.
72+
73+
HumanName has no custom copy hooks, so deepcopy recurses into its
74+
Constants (`.C`), and previously into `.C.regexes`, whose __getattr__
75+
answered copy's __deepcopy__ probe with a re.Pattern — making
76+
deepcopy of *any* HumanName raise TypeError.
77+
"""
78+
hn = HumanName("Dr. John P. Doe-Ray, CLU")
79+
80+
dup = copy.deepcopy(hn)
81+
82+
self.assertEqual(str(dup), str(hn))
83+
84+
def test_name_instance_deepcopy_isolates_instance_config(self) -> None:
85+
"""A deep-copied HumanName with its own config must be independent."""
86+
hn = HumanName("Smith, Dr. John", None)
87+
hn.C.titles.add('chancellor')
88+
89+
dup = copy.deepcopy(hn)
90+
dup.C.titles.add('marker')
91+
92+
self.assertIn('chancellor', dup.C.titles)
93+
self.assertNotIn('marker', hn.C.titles)
94+
6995
def test_comparison(self) -> None:
7096
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
7197
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")

0 commit comments

Comments
 (0)