Skip to content

Commit 69bc98c

Browse files
committed
Reject bare strings and 2-char string elements in TupleManager
TupleManager (and RegexTupleManager) inherited dict.__init__ unchanged, so string inputs behaved inconsistently with the guarded SetManager (#238): TupleManager('ab') raised a cryptic dict-internals ValueError naming no argument, and TupleManager(['ab', 'cd']) silently shredded into {'a': 'b', 'c': 'd'} since a 2-character string is a valid dict-update sequence element. Give TupleManager its own __init__ that rejects a bare str/bytes argument and any str/bytes element within an iterable, mirroring SetManager's error-message style. Fixes #242
1 parent a42f71b commit 69bc98c

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

nameparser/config/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,42 @@ class TupleManager(dict[str, T]):
276276
1.3.0 these constants were tuples of pairs.
277277
'''
278278

279+
def __init__(self, *args: Any, **kwargs: Any) -> None:
280+
# dict.__init__ accepts a bare str/bytes as an iterable-of-pairs
281+
# argument (each character iterates further, and dict() only
282+
# complains once it hits a "pair" of the wrong length) and accepts an
283+
# iterable of 2-character strings as if each one were a (key, value)
284+
# pair, silently shredding it -- mirrors SetManager's guard against
285+
# the same class of mistake (#238), applied to the mapping
286+
# constructor's own failure modes (#242).
287+
if args:
288+
arg = args[0]
289+
if isinstance(arg, bytes):
290+
raise TypeError(
291+
"expected a mapping or iterable of (key, value) pairs, got a "
292+
f"single bytes; decode it first: [{arg!r}.decode()]"
293+
)
294+
if isinstance(arg, str):
295+
raise TypeError(
296+
"expected a mapping or iterable of (key, value) pairs, got a "
297+
f"single str; wrap it in a list: [{arg!r}]"
298+
)
299+
if not isinstance(arg, Mapping):
300+
checked = []
301+
for item in arg:
302+
if isinstance(item, (str, bytes)):
303+
raise TypeError(
304+
"expected (key, value) pairs, got a "
305+
f"{'bytes' if isinstance(item, bytes) else 'str'} "
306+
f"element {item!r}; a 2-character string silently "
307+
"splits into a key and a value"
308+
)
309+
checked.append(item)
310+
arg = checked
311+
super().__init__(arg, **kwargs)
312+
else:
313+
super().__init__(**kwargs)
314+
279315
def __getattr__(self, attr: str) -> T | None:
280316
# Otherwise the dict default (None) is mistaken for a real protocol hook.
281317
if _is_dunder(attr):

tests/test_constants.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,35 @@ def test_set_manager_non_str_elements_raise_typeerror(self) -> None:
178178
with pytest.raises(TypeError, match=r"expected str elements"):
179179
SetManager(['dr']) | [1] # type: ignore[list-item]
180180

181+
def test_tuplemanager_bare_string_raises_typeerror(self) -> None:
182+
# dict(['ab', 'cd']) shreds each 2-char string into a key/value pair
183+
# silently -- and dict('ab') itself raises a cryptic "dictionary update
184+
# sequence element #0 has length 1; 2 is required" naming no argument
185+
# and suggesting no fix (#242)
186+
with pytest.raises(TypeError, match=r"wrap it in a list"):
187+
TupleManager('ab')
188+
189+
def test_tuplemanager_bytes_raises_with_decode_hint(self) -> None:
190+
with pytest.raises(TypeError, match=r"decode it first"):
191+
TupleManager(b'ab') # type: ignore[arg-type]
192+
193+
def test_tuplemanager_string_element_raises_typeerror(self) -> None:
194+
# the silent variant: an iterable of 2-character strings is a valid
195+
# dict-update sequence, so each one shreds into a key/value pair
196+
with pytest.raises(TypeError, match=r"key and a value"):
197+
TupleManager(['ab', 'cd'])
198+
199+
def test_constants_capitalization_exceptions_string_elements_raise(self) -> None:
200+
with pytest.raises(TypeError, match=r"key and a value"):
201+
Constants(capitalization_exceptions=['ii'])
202+
203+
def test_tuplemanager_accepts_mapping_and_pairs(self) -> None:
204+
# the guard must not reject the two legitimate constructor shapes
205+
tm = TupleManager({'a': '1'})
206+
self.assertEqual(tm.a, '1')
207+
tm2 = TupleManager([('b', '2')])
208+
self.assertEqual(tm2.b, '2')
209+
181210
def test_remove_title(self) -> None:
182211
hn = HumanName("Hon Solo", constants=None)
183212
start_len = len(hn.C.titles)

0 commit comments

Comments
 (0)