Skip to content

Commit 54023ca

Browse files
derek73claude
andcommitted
Review follow-ups: guard set operators and fix the bytes hint
The Set mixin's __or__/__and__ hand _from_iterable a generator, so the new __init__ guard never saw a bare-string operand: c.titles |= 'esq' still silently added 'e', 's', 'q' as titles, while __sub__/__xor__ raised only by accident of constructing from the operand. Route all four operators (and reflections) through a shared bare-string check. typeshed declares narrower AbstractSet operands and omits the runtime ABC's __rsub__, hence the targeted type ignores. The bytes branch of the error hint was itself a trap: following "wrap it in a list: [b'dr']" produces a set whose bytes elements never match parsed str tokens — another silent failure. bytes now get "decode it first: [b'dr'.decode()]". Also correct the SetManager class docstring's "Only special functionality" claim (the guards are user-visible API now) and drop add()'s false "Can pass a list of strings" (that raises AttributeError). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 89796e5 commit 54023ca

3 files changed

Lines changed: 85 additions & 13 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Release Log
22
===========
33
* 1.3.0 - Unreleased
4-
- Fix a bare string passed to a set-backed ``Constants`` argument (e.g. ``Constants(titles='dr')``) or to ``SetManager`` being silently split into single characters, replacing the default set and producing wrong parses with no error; it now raises ``TypeError`` with the suggested fix (wrap it in a list) (closes #238)
4+
- Fix a bare string passed to a set-backed ``Constants`` argument (e.g. ``Constants(titles='dr')``), to ``SetManager``, or as a ``SetManager`` set-operator operand (e.g. ``constants.titles |= 'esq'``) being silently split into single characters, replacing or polluting the set and producing wrong parses with no error; it now raises ``TypeError`` with the suggested fix wrap strings in a list, decode ``bytes`` first (closes #238)
55
- Fix the ``constants`` constructor argument silently discarding ``Constants`` *subclass* instances: the exact-type check replaced them with fresh defaults, throwing away the caller's configuration. Subclass instances are now used as given; anything that is neither ``None`` nor a ``Constants`` instance now raises ``TypeError`` instead of being silently swapped for defaults (closes #226)
66
- Fix ``IndexError`` in ``initials()``/``initials_list()`` when a ``*_list`` attribute was assigned directly with an element containing unnormalized whitespace (e.g. ``name.middle_list = ['Q R']``), bypassing the parser's whitespace normalization (closes #232)
77
- Fix ``HumanName`` acting as its own iterator with a stored cursor: breaking out of a loop, iterating in nested loops, or calling ``len(name)`` mid-loop corrupted subsequent iteration; ``iter(name)`` now returns a fresh independent iterator each time. ``next(name)`` on the instance itself (undocumented) now raises ``TypeError`` — call ``next(iter(name))`` instead (closes #225)

nameparser/config/__init__.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,23 +56,35 @@ class SetManager(Set):
5656
Easily add and remove config variables per module or instance. Subclass of
5757
``collections.abc.Set``.
5858
59-
Only special functionality beyond that provided by set() is
60-
to normalize constants for comparison (lower case, no periods)
61-
when they are add()ed and remove()d and allow passing multiple
62-
string arguments to the :py:func:`add()` and :py:func:`remove()` methods.
59+
Special functionality beyond that provided by set() is to normalize
60+
constants for comparison (lower case, no periods) when they are add()ed
61+
and remove()d, and to allow passing multiple string arguments to the
62+
:py:func:`add()` and :py:func:`remove()` methods. The constructor and
63+
the set operators also reject a bare string with ``TypeError``, since
64+
e.g. ``set('dr')`` would silently build a set of single characters.
6365
6466
'''
6567

6668
_on_change: Callable[[], None] | None
6769

68-
def __init__(self, elements: Iterable[str]) -> None:
69-
# a bare string is an iterable of its characters, so set(elements)
70-
# would silently build a set of single characters (#238)
71-
if isinstance(elements, (str, bytes)):
70+
@staticmethod
71+
def _reject_bare_string(value: object) -> None:
72+
# a bare string is an iterable of its characters, so set(value)
73+
# would silently build a set of single characters — and bytes
74+
# iterates to ints, which can never match parsed tokens (#238)
75+
if isinstance(value, bytes):
76+
raise TypeError(
77+
"expected an iterable of strings, got a single bytes; "
78+
f"decode it first: [{value!r}.decode()]"
79+
)
80+
if isinstance(value, str):
7281
raise TypeError(
73-
"expected an iterable of strings, got a single "
74-
f"{type(elements).__name__}; wrap it in a list: [{elements!r}]"
82+
"expected an iterable of strings, got a single str; "
83+
f"wrap it in a list: [{value!r}]"
7584
)
85+
86+
def __init__(self, elements: Iterable[str]) -> None:
87+
self._reject_bare_string(elements)
7688
self.elements = set(elements)
7789
# Optional invalidation hook, wired by an owning Constants so that
7890
# in-place add()/remove() can clear its cached suffixes_prefixes_titles
@@ -97,6 +109,39 @@ def __contains__(self, value: object) -> bool:
97109
def __len__(self) -> int:
98110
return len(self.elements)
99111

112+
# Set's mixin __or__/__and__ hand _from_iterable a generator, so the
113+
# __init__ guard never sees a bare-string operand (c.titles |= 'esq'
114+
# would silently add 'e', 's', 'q'); __sub__/__xor__ raised only by
115+
# accident of constructing from the operand. Check all four uniformly.
116+
# the runtime ABC accepts any Iterable operand, so annotate honestly and
117+
# ignore typeshed's narrower AbstractSet declarations
118+
def __or__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
119+
self._reject_bare_string(other)
120+
return super().__or__(other) # type: ignore[operator, return-value]
121+
122+
__ror__ = __or__
123+
124+
def __and__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
125+
self._reject_bare_string(other)
126+
return super().__and__(other) # type: ignore[operator, return-value]
127+
128+
__rand__ = __and__
129+
130+
def __sub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
131+
self._reject_bare_string(other)
132+
return super().__sub__(other) # type: ignore[operator, return-value]
133+
134+
def __rsub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
135+
self._reject_bare_string(other)
136+
# typeshed omits Set.__rsub__, but the runtime ABC defines it
137+
return super().__rsub__(other) # type: ignore[misc, operator, return-value]
138+
139+
def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
140+
self._reject_bare_string(other)
141+
return super().__xor__(other) # type: ignore[operator, return-value]
142+
143+
__rxor__ = __xor__
144+
100145
def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
101146
"""
102147
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
@@ -118,7 +163,7 @@ def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
118163
def add(self, *strings: str) -> Self:
119164
"""
120165
Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set.
121-
Can pass a list of strings. Returns ``self`` for chaining.
166+
Returns ``self`` for chaining.
122167
"""
123168
for s in strings:
124169
self.add_with_encoding(s)

tests/test_constants.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,36 @@ def test_constants_bare_string_kwarg_raises_typeerror(self) -> None:
5555
def test_set_manager_bare_string_raises_typeerror(self) -> None:
5656
with pytest.raises(TypeError, match=r"wrap it in a list"):
5757
SetManager('dr')
58-
with pytest.raises(TypeError, match=r"wrap it in a list"):
58+
59+
def test_set_manager_bytes_raises_with_decode_hint(self) -> None:
60+
# a "wrap it in a list" hint would be a trap for bytes: [b'dr'] is
61+
# accepted but its elements never match parsed str tokens
62+
with pytest.raises(TypeError, match=r"decode it first"):
5963
SetManager(b'dr') # type: ignore[arg-type]
6064

65+
def test_set_manager_bare_string_operand_raises_typeerror(self) -> None:
66+
# Set's mixin __or__/__and__ hand _from_iterable a generator, so the
67+
# constructor guard alone never sees a bare-string operand; without
68+
# an operand check, c.titles |= 'esq' silently adds 'e', 's', 'q'
69+
sm = SetManager(['dr', 'mr'])
70+
for op in (lambda: sm | 'abc',
71+
lambda: 'abc' | sm,
72+
lambda: sm & 'abc',
73+
lambda: 'abc' & sm,
74+
lambda: sm - 'abc',
75+
lambda: sm ^ 'abc'):
76+
with pytest.raises(TypeError, match=r"wrap it in a list"):
77+
op()
78+
79+
def test_set_manager_operators_accept_lists(self) -> None:
80+
c = Constants()
81+
with pytest.raises(TypeError, match=r"wrap it in a list"):
82+
c.titles |= 'esq'
83+
c.titles |= ['esq']
84+
self.assertIn('esq', c.titles)
85+
hn = HumanName("Esq Jane Smith", constants=c)
86+
self.m(hn.title, "Esq", hn)
87+
6188
def test_remove_title(self) -> None:
6289
hn = HumanName("Hon Solo", constants=None)
6390
start_len = len(hn.C.titles)

0 commit comments

Comments
 (0)