Skip to content

Commit de85734

Browse files
derek73claude
andcommitted
Normalize SetManager constructor elements and validate element types
Review follow-up on the operand-normalization commit: the constructor still stored raw elements, which that commit turned from a latent inconsistency into active misfires — SetManager(['Dr.']) & ['Dr.'] returned empty and - silently no-opped against the exact spelling stored in the set, Constants(titles=[...]) remained the last silently-dead config path (raw 'Chemistry' can never match the parser's lc() lookups), and titles vs the suffixes_prefixes_titles union disagreed about the same token. Fold the operand helper into _normalized_elements, shared by __init__ and all operators, so every entry is stored in the form lookups expect. Junk elements now raise a curated TypeError instead of failing cryptically inside lc() (bytes, int) or being silently transmuted (None became ''). Also scope the docstring claim that operators normalize "the same way add() does" (add() additionally decodes bytes), correct "strips periods" to leading/trailing in prose, and pin __rxor__ separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d12f3c4 commit de85734

3 files changed

Lines changed: 77 additions & 33 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Release Log
22
===========
33
* 1.3.0 - Unreleased
44
- 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)
5-
- Fix ``SetManager`` set operators skipping the lowercase/strip-periods normalization that ``add()`` applies: ``constants.titles |= ['Esq.']`` kept a raw ``'Esq.'`` the parser's lookups could never match, and ``titles & ['Dr.']`` missed ``'dr'``; operator operands are now normalized, so operator results behave like ``add()``-built sets
5+
- Fix ``SetManager`` set operators and the constructor skipping the lowercase/strip-edge-periods normalization that ``add()`` applies: ``constants.titles |= ['Esq.']`` kept a raw ``'Esq.'`` the parser's lookups could never match, ``titles & ['Dr.']`` missed ``'dr'``, and ``Constants(titles=[...])`` stored raw elements that silently never matched; elements and operands are now normalized everywhere, and non-``str`` elements (``bytes``, ``None``, numbers) raise ``TypeError`` instead of crashing cryptically or being coerced
66
- 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)
77
- 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)
88
- 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: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ class SetManager(Set):
5757
``collections.abc.Set``.
5858
5959
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, and
65-
the set operators normalize their operands the same way :py:func:`add()`
66-
does, so operator results keep the normalized-elements invariant.
60+
constants for comparison (lowercase, leading/trailing periods stripped)
61+
when they are add()ed and remove()d, and to allow passing multiple
62+
string arguments to the :py:func:`add()` and :py:func:`remove()`
63+
methods. The constructor and the set operators apply the same
64+
normalization to their elements and operands, so every entry is stored
65+
in the form the parser's lookups expect, and they reject a bare string
66+
with ``TypeError``, since e.g. ``set('dr')`` would silently build a set
67+
of single characters.
6768
6869
'''
6970

@@ -85,9 +86,28 @@ def _reject_bare_string(value: object) -> None:
8586
f"wrap it in a list: [{value!r}]"
8687
)
8788

89+
@classmethod
90+
def _normalized_elements(cls, elements: Iterable[str]) -> set[str]:
91+
# apply the same lc() normalization (lowercase, strip leading/
92+
# trailing periods) that add() applies, and reject junk elements:
93+
# lc() on bytes or int crashes without naming the culprit, and
94+
# lc(None) silently transmutes to ''
95+
cls._reject_bare_string(elements)
96+
normalized = set()
97+
for s in elements:
98+
if isinstance(s, bytes):
99+
raise TypeError(
100+
f"expected str elements, got bytes; decode it first: {s!r}.decode()"
101+
)
102+
if not isinstance(s, str):
103+
raise TypeError(
104+
f"expected str elements, got {type(s).__name__}: {s!r}"
105+
)
106+
normalized.add(lc(s))
107+
return normalized
108+
88109
def __init__(self, elements: Iterable[str]) -> None:
89-
self._reject_bare_string(elements)
90-
self.elements = set(elements)
110+
self.elements = self._normalized_elements(elements)
91111
# Optional invalidation hook, wired by an owning Constants so that
92112
# in-place add()/remove() can clear its cached suffixes_prefixes_titles
93113
# union. None when the manager is used standalone.
@@ -111,41 +131,36 @@ def __contains__(self, value: object) -> bool:
111131
def __len__(self) -> int:
112132
return len(self.elements)
113133

114-
# Set's mixin __or__/__and__ hand _from_iterable a generator, so the
115-
# __init__ guard never sees a bare-string operand (c.titles |= 'esq'
116-
# would silently add 'e', 's', 'q'); __sub__/__xor__ raised only by
117-
# accident of constructing from the operand. Check all four uniformly.
118-
# Operands are also normalized the way add() normalizes elements: the
119-
# mixins build results from raw operand elements and test them against
120-
# the stored (normalized) ones, so without this (titles | ['Esq.'])
121-
# keeps a raw 'Esq.' the parser's lc() lookups can never match, and
122-
# (titles & ['Dr.']) misses 'dr' — silently broken config either way.
123-
@classmethod
124-
def _normalized_operand(cls, other: Iterable[str]) -> set[str]:
125-
cls._reject_bare_string(other)
126-
return {lc(s) for s in other}
127-
134+
# Set's mixin operators compare operand and stored elements without
135+
# normalizing (and __or__/__and__ hand _from_iterable a generator, so
136+
# the __init__ guard alone never sees a bare-string operand: c.titles
137+
# |= 'esq' would silently add 'e', 's', 'q'). Normalize every operand
138+
# through _normalized_elements so operator results behave like
139+
# add()-built sets: without it (titles | ['Esq.']) keeps a raw 'Esq.'
140+
# the parser's lc() lookups can never match, and (titles & ['Dr.'])
141+
# misses the stored 'dr'.
142+
#
128143
# the runtime ABC accepts any Iterable operand, so annotate honestly and
129144
# ignore typeshed's narrower AbstractSet declarations
130145
def __or__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
131-
return super().__or__(self._normalized_operand(other)) # type: ignore[operator, return-value]
146+
return super().__or__(self._normalized_elements(other)) # type: ignore[operator, return-value]
132147

133148
__ror__ = __or__
134149

135150
def __and__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
136-
return super().__and__(self._normalized_operand(other)) # type: ignore[operator, return-value]
151+
return super().__and__(self._normalized_elements(other)) # type: ignore[operator, return-value]
137152

138153
__rand__ = __and__
139154

140155
def __sub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
141-
return super().__sub__(self._normalized_operand(other)) # type: ignore[operator, return-value]
156+
return super().__sub__(self._normalized_elements(other)) # type: ignore[operator, return-value]
142157

143158
def __rsub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
144159
# typeshed omits Set.__rsub__, but the runtime ABC defines it
145-
return super().__rsub__(self._normalized_operand(other)) # type: ignore[misc, operator, return-value]
160+
return super().__rsub__(self._normalized_elements(other)) # type: ignore[misc, operator, return-value]
146161

147162
def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
148-
return super().__xor__(self._normalized_operand(other)) # type: ignore[operator, return-value]
163+
return super().__xor__(self._normalized_elements(other)) # type: ignore[operator, return-value]
149164

150165
__rxor__ = __xor__
151166

tests/test_constants.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,11 @@ def test_set_manager_operators_accept_lists(self) -> None:
8686
self.m(hn.title, "Esq", hn)
8787

8888
def test_set_manager_operators_normalize_like_add(self) -> None:
89-
# add() lowercases and strips periods; without the same normalization
90-
# of operator operands, (titles | ['Esq.']) contains raw 'Esq.', which
91-
# the parser's lc()-based lookups can never match — silently broken
92-
# config, same failure family as the bare-string shredding (#238)
89+
# add() lowercases and strips leading/trailing periods; without the
90+
# same normalization of operator operands, (titles | ['Esq.']) keeps
91+
# a raw 'Esq.', which the parser's lc()-based lookups can never match
92+
# — silently broken config, same failure family as the bare-string
93+
# shredding (#238)
9394
sm = SetManager(['dr', 'mr'])
9495
self.assertEqual((sm | ['Esq.']).elements, {'dr', 'mr', 'esq'})
9596
self.assertEqual((['Esq.', 'Dr.'] | sm).elements, {'dr', 'mr', 'esq'})
@@ -98,6 +99,34 @@ def test_set_manager_operators_normalize_like_add(self) -> None:
9899
self.assertEqual((sm - ['Dr.']).elements, {'mr'})
99100
self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'})
100101
self.assertEqual((sm ^ ['Dr.', 'Esq.']).elements, {'mr', 'esq'})
102+
# pins __rxor__ separately in case it ever stops aliasing __xor__
103+
self.assertEqual((['Dr.', 'Esq.'] ^ sm).elements, {'mr', 'esq'})
104+
105+
def test_set_manager_constructor_normalizes_like_add(self) -> None:
106+
# without constructor normalization the operators misfire against
107+
# the exact spelling visibly stored in the set: & returns empty
108+
# and - silently no-ops
109+
sm = SetManager(['Dr.', 'MR'])
110+
self.assertEqual(sm.elements, {'dr', 'mr'})
111+
self.assertEqual((sm & ['Dr.']).elements, {'dr'})
112+
self.assertEqual((sm - ['Dr.']).elements, {'mr'})
113+
114+
def test_constants_kwarg_elements_are_normalized(self) -> None:
115+
# Constants(titles=[...]) was the last silently-dead config path:
116+
# a raw 'Chemistry' element can never match the parser's lc() lookups
117+
c = Constants(titles=['chancellor', 'Chemistry'])
118+
hn = HumanName("Chemistry Jane Smith", constants=c)
119+
self.m(hn.title, "Chemistry", hn)
120+
121+
def test_set_manager_non_str_elements_raise_typeerror(self) -> None:
122+
# lc() on junk elements either crashes context-free (bytes, int) or
123+
# silently transmutes None into '' — raise a curated error instead
124+
with pytest.raises(TypeError, match=r"decode it first"):
125+
SetManager([b'dr']) # type: ignore[list-item]
126+
with pytest.raises(TypeError, match=r"expected str elements"):
127+
SetManager([None]) # type: ignore[list-item]
128+
with pytest.raises(TypeError, match=r"expected str elements"):
129+
SetManager(['dr']) | [1] # type: ignore[list-item]
101130

102131
def test_set_manager_operator_result_parses_when_wired_into_constants(self) -> None:
103132
# end-to-end: an operator-built set must behave like an add()-built

0 commit comments

Comments
 (0)