Skip to content

Commit d12f3c4

Browse files
derek73claude
andcommitted
Normalize SetManager operator operands like add() does
The Set mixin operators build results from raw operand elements and test them against the stored (normalized) ones, so even with the bare-string guard, (titles | ['Esq.']) kept a raw 'Esq.' the parser's lc() lookups could never match, and (titles & ['Dr.']) missed 'dr' — the same silently-broken-config family as #238, one step from the guarded case. Operands now pass through lc() in the existing operator overrides, so operator results obey the same normalized-elements invariant as add()-built sets. Idempotent for already-normalized operands, so the internal suffixes_prefixes_titles union is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 54023ca commit d12f3c4

3 files changed

Lines changed: 41 additions & 11 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +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
56
- 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)
67
- 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)
78
- 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: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ class SetManager(Set):
6161
and remove()d, and to allow passing multiple string arguments to the
6262
:py:func:`add()` and :py:func:`remove()` methods. The constructor and
6363
the set operators also reject a bare string with ``TypeError``, since
64-
e.g. ``set('dr')`` would silently build a set of single characters.
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.
6567
6668
'''
6769

@@ -113,32 +115,37 @@ def __len__(self) -> int:
113115
# __init__ guard never sees a bare-string operand (c.titles |= 'esq'
114116
# would silently add 'e', 's', 'q'); __sub__/__xor__ raised only by
115117
# 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+
116128
# the runtime ABC accepts any Iterable operand, so annotate honestly and
117129
# ignore typeshed's narrower AbstractSet declarations
118130
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]
131+
return super().__or__(self._normalized_operand(other)) # type: ignore[operator, return-value]
121132

122133
__ror__ = __or__
123134

124135
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]
136+
return super().__and__(self._normalized_operand(other)) # type: ignore[operator, return-value]
127137

128138
__rand__ = __and__
129139

130140
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]
141+
return super().__sub__(self._normalized_operand(other)) # type: ignore[operator, return-value]
133142

134143
def __rsub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
135-
self._reject_bare_string(other)
136144
# typeshed omits Set.__rsub__, but the runtime ABC defines it
137-
return super().__rsub__(other) # type: ignore[misc, operator, return-value]
145+
return super().__rsub__(self._normalized_operand(other)) # type: ignore[misc, operator, return-value]
138146

139147
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]
148+
return super().__xor__(self._normalized_operand(other)) # type: ignore[operator, return-value]
142149

143150
__rxor__ = __xor__
144151

tests/test_constants.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ def test_set_manager_operators_accept_lists(self) -> None:
8585
hn = HumanName("Esq Jane Smith", constants=c)
8686
self.m(hn.title, "Esq", hn)
8787

88+
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)
93+
sm = SetManager(['dr', 'mr'])
94+
self.assertEqual((sm | ['Esq.']).elements, {'dr', 'mr', 'esq'})
95+
self.assertEqual((['Esq.', 'Dr.'] | sm).elements, {'dr', 'mr', 'esq'})
96+
self.assertEqual((sm & ['Dr.']).elements, {'dr'})
97+
self.assertEqual((['Dr.'] & sm).elements, {'dr'})
98+
self.assertEqual((sm - ['Dr.']).elements, {'mr'})
99+
self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'})
100+
self.assertEqual((sm ^ ['Dr.', 'Esq.']).elements, {'mr', 'esq'})
101+
102+
def test_set_manager_operator_result_parses_when_wired_into_constants(self) -> None:
103+
# end-to-end: an operator-built set must behave like an add()-built
104+
# one once assigned back to Constants
105+
c = Constants()
106+
c.titles |= ['Esq.']
107+
hn = HumanName("Esq Jane Smith", constants=c)
108+
self.m(hn.title, "Esq", hn)
109+
88110
def test_remove_title(self) -> None:
89111
hn = HumanName("Hon Solo", constants=None)
90112
start_len = len(hn.C.titles)

0 commit comments

Comments
 (0)