Skip to content

Commit 7e81187

Browse files
derek73claude
andcommitted
Restore v1.3/1.4 manager hardening in the shim (#221/#238/#241/#242/#260 + operators)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7bf53f3 commit 7e81187

2 files changed

Lines changed: 351 additions & 35 deletions

File tree

nameparser/_config_shim.py

Lines changed: 219 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
import functools
2323
import warnings
24-
from collections.abc import Callable, Iterable, Iterator, KeysView, Mapping
24+
from collections.abc import (
25+
Callable, Iterable, Iterator, KeysView, Mapping, Set,
26+
)
2527
from typing import NamedTuple, Self
2628

2729
from nameparser._lexicon import Lexicon
@@ -30,6 +32,47 @@
3032
from nameparser.util import lc
3133

3234

35+
def _reject_bare_str_or_bytes(value: object, expected: str) -> None:
36+
# A bare string is an iterable of its characters, so e.g. SetManager('dr')
37+
# would silently shred it into {'d', 'r'} instead of raising -- shared by
38+
# SetManager's constructor/operands (#238/#241) and TupleManager's
39+
# constructor (#242). Ported from v1's `_reject_bare_str_or_bytes`.
40+
if isinstance(value, bytes):
41+
raise TypeError(
42+
f"expected {expected}, got a single bytes; decode it first: "
43+
f"{value!r}.decode()"
44+
)
45+
if isinstance(value, str):
46+
raise TypeError(
47+
f"expected {expected}, got a single str; wrap it in a list: "
48+
f"[{value!r}]"
49+
)
50+
51+
52+
def _lc_validated(s: object) -> str:
53+
# Validates and lc()-normalizes a single element -- shared by bulk
54+
# iterable normalization (constructor/operands) and add()'s per-string
55+
# loop, so every path raises the same #238/#245-shaped TypeError instead
56+
# of lc() crashing cryptically (bytes) or silently transmuting (None).
57+
if isinstance(s, bytes):
58+
raise TypeError(
59+
f"expected a str, got bytes; decode it first: {s!r}.decode()"
60+
)
61+
if not isinstance(s, str):
62+
raise TypeError(f"expected a str, got {type(s).__name__}: {s!r}")
63+
return lc(s)
64+
65+
66+
def _normalize_iterable_of_strings(
67+
elements: object, expected: str = "an iterable of strings") -> set[str]:
68+
# a SetManager's elements were already validated/normalized when it was
69+
# built, so copy them instead of re-validating (v1's fast path)
70+
if isinstance(elements, SetManager):
71+
return set(elements._elements)
72+
_reject_bare_str_or_bytes(elements, expected)
73+
return {_lc_validated(s) for s in elements} # type: ignore[attr-defined]
74+
75+
3376
class SetManager:
3477
"""v1 ``SetManager`` surface over a plain set of ``lc()``-normalized
3578
strings. Mutations call ``_on_change`` (the owning Constants'
@@ -43,8 +86,16 @@ class SetManager:
4386
_on_change: Callable[[], None] | None
4487

4588
def __init__(self, elements: Iterable[str] = (),
46-
_on_change: Callable[[], None] | None = None) -> None:
47-
self._elements = {lc(e) for e in elements}
89+
_on_change: Callable[[], None] | None = None,
90+
_field: str | None = None) -> None:
91+
# _field carries a Constants field name (e.g. "titles") through to
92+
# the bare-str/bytes guard's message, when this SetManager is being
93+
# built on behalf of a named Constants field (constructor kwarg or
94+
# __setattr__ auto-wrap); a direct SetManager(...) call gets the
95+
# generic v1 message instead.
96+
expected = f"{_field} to be an iterable of strings" if _field \
97+
else "an iterable of strings"
98+
self._elements = _normalize_iterable_of_strings(elements, expected)
4899
self._on_change = _on_change
49100

50101
def _changed(self) -> None:
@@ -58,7 +109,7 @@ def add(self, *strings: str) -> SetManager:
58109
# bump the owner's generation
59110
changed = False
60111
for s in strings:
61-
normalized = lc(s)
112+
normalized = _lc_validated(s) # TypeError on bytes (#245)
62113
if normalized not in self._elements:
63114
self._elements.add(normalized)
64115
changed = True
@@ -82,6 +133,28 @@ def remove(self, *strings: str) -> SetManager:
82133
self._changed()
83134
return self
84135

136+
def discard(self, *strings: str) -> SetManager:
137+
"""Remove the normalized string arguments from the set if
138+
present; missing members are ignored, like ``set.discard``.
139+
Returns ``self`` for chaining."""
140+
changed = False
141+
for s in strings:
142+
normalized = lc(s)
143+
if normalized in self._elements:
144+
self._elements.discard(normalized)
145+
changed = True
146+
if changed:
147+
self._changed()
148+
return self
149+
150+
def clear(self) -> SetManager:
151+
"""Remove all entries from the set. Returns ``self`` for
152+
chaining."""
153+
if self._elements:
154+
self._elements.clear()
155+
self._changed()
156+
return self
157+
85158
def __contains__(self, item: object) -> bool:
86159
return isinstance(item, str) and lc(item) in self._elements
87160

@@ -100,28 +173,62 @@ def __eq__(self, other: object) -> bool:
100173

101174
__hash__ = None # type: ignore[assignment] # mutable; v1 parity
102175

103-
def _as_operand(self, other: object) -> set[str]:
104-
if isinstance(other, SetManager):
105-
return other._elements
106-
if isinstance(other, (set, frozenset)):
107-
return {lc(e) if isinstance(e, str) else e for e in other}
108-
raise TypeError(f"unsupported operand type for SetManager: {other!r}")
176+
# -- set operators: accept ANY iterable (v1.3 normalize-everywhere) -----
177+
# A bare str/bytes operand raises TypeError via _normalize_iterable_of_
178+
# strings rather than iterating its characters (#238/#241); everything
179+
# else (list, generator, set, another SetManager, ...) is normalized
180+
# through lc() before the plain set op runs.
109181

110182
def __or__(self, other: object) -> set[str]:
111-
return self._elements | self._as_operand(other)
183+
return self._elements | _normalize_iterable_of_strings(other)
112184

113185
__ror__ = __or__
114186

115187
def __and__(self, other: object) -> set[str]:
116-
return self._elements & self._as_operand(other)
188+
return self._elements & _normalize_iterable_of_strings(other)
117189

118190
__rand__ = __and__
119191

120192
def __sub__(self, other: object) -> set[str]:
121-
return self._elements - self._as_operand(other)
193+
return self._elements - _normalize_iterable_of_strings(other)
122194

123195
def __rsub__(self, other: object) -> set[str]:
124-
return self._as_operand(other) - self._elements
196+
return _normalize_iterable_of_strings(other) - self._elements
197+
198+
def __xor__(self, other: object) -> set[str]:
199+
return self._elements ^ _normalize_iterable_of_strings(other)
200+
201+
__rxor__ = __xor__ # symmetric difference is commutative, like v1
202+
203+
# -- comparisons: v1 subclassed collections.abc.Set, whose __le__/__lt__/
204+
# __ge__/__gt__ mixins only accept another Set-registered operand (set,
205+
# frozenset, or another Set subclass) -- NOT an arbitrary iterable like
206+
# list, unlike the operators above. Mirrored here since this SetManager
207+
# doesn't itself subclass the ABC.
208+
209+
def __le__(self, other: object) -> bool:
210+
if not isinstance(other, (SetManager, Set)):
211+
return NotImplemented
212+
if len(self) > len(other):
213+
return False
214+
return all(elem in other for elem in self)
215+
216+
def __lt__(self, other: object) -> bool:
217+
if not isinstance(other, (SetManager, Set)):
218+
return NotImplemented
219+
return len(self) < len(other) and self.__le__(other)
220+
221+
def __ge__(self, other: object) -> bool:
222+
if not isinstance(other, (SetManager, Set)):
223+
return NotImplemented
224+
if len(self) < len(other):
225+
return False
226+
return all(elem in self for elem in other)
227+
228+
def __gt__(self, other: object) -> bool:
229+
if not isinstance(other, (SetManager, Set)):
230+
return NotImplemented
231+
return len(self) > len(other) and self.__ge__(other)
125232

126233
def __repr__(self) -> str:
127234
# Sorted so repr is stable across runs -- set() iteration order
@@ -160,6 +267,26 @@ class TupleManager(dict[str, object]):
160267
def __init__(self, *args: object,
161268
_on_change: Callable[[], None] | None = None,
162269
**kwargs: object) -> None:
270+
if args:
271+
# #242: a bare str/bytes silently shreds into a garbage mapping
272+
# (dict's own error), and an iterable of short strings silently
273+
# splits each one into a (key, value) pair -- ported from v1's
274+
# TupleManager.__init__ guard.
275+
arg = args[0]
276+
_reject_bare_str_or_bytes(
277+
arg, "a mapping or iterable of (key, value) pairs")
278+
if not isinstance(arg, Mapping):
279+
checked = []
280+
for item in arg: # type: ignore[attr-defined]
281+
if isinstance(item, (str, bytes)):
282+
kind = "bytes" if isinstance(item, bytes) else "str"
283+
raise TypeError(
284+
f"expected (key, value) pairs, got a {kind} "
285+
f"element {item!r}; a 2-character string "
286+
"silently splits into a key and a value"
287+
)
288+
checked.append(item)
289+
args = (checked, *args[1:])
163290
super().__init__(*args, **kwargs)
164291
self._on_change = _on_change
165292

@@ -177,7 +304,31 @@ def __getattr__(self, name: str) -> object:
177304
try:
178305
return self[name]
179306
except KeyError:
180-
raise AttributeError(f"no key {name!r} in this manager") from None
307+
# #256: name the known keys, like v1's 1.4 deprecation warning
308+
# did -- this shim only speaks 2.0, so what was a warning there
309+
# is a hard AttributeError here.
310+
raise AttributeError(
311+
f"{name!r} is not a known key "
312+
f"({', '.join(sorted(self))}); use .get() for intentional "
313+
"soft access."
314+
) from None
315+
316+
def __setattr__(self, name: str, value: object) -> None:
317+
# v1 parity: dunder probes (typing's __orig_class__, etc.) and this
318+
# shim's own _on_change hook get real object-attribute storage;
319+
# every other name -- including single-underscore ones, per v1 --
320+
# routes to the dict so `t.mcdonald = 'x'` and `t['mcdonald'] = 'x'`
321+
# are the same operation.
322+
if name == "_on_change" or (name.startswith("__") and name.endswith("__")):
323+
object.__setattr__(self, name, value)
324+
else:
325+
self[name] = value
326+
327+
def __delattr__(self, name: str) -> None:
328+
if name == "_on_change" or (name.startswith("__") and name.endswith("__")):
329+
object.__delattr__(self, name)
330+
else:
331+
del self[name]
181332

182333
def __setitem__(self, key: str, value: object) -> None:
183334
super().__setitem__(key, value)
@@ -376,6 +527,24 @@ def _raise_readonly(name: str) -> None:
376527
_MANAGER_FIELDS = _SET_FIELDS + (
377528
"capitalization_exceptions", "nickname_delimiters", "maiden_delimiters",
378529
)
530+
531+
#: v1's Constants.__repr__ field order (#221) -- kept as its own tuple
532+
#: rather than reusing _SET_FIELDS, whose order differs (v1 lists
533+
#: suffix_acronyms_ambiguous last, not fourth).
534+
_REPR_COLLECTION_ATTRS = (
535+
"prefixes", "suffix_acronyms", "suffix_not_acronyms", "titles",
536+
"first_name_titles", "conjunctions", "bound_first_names",
537+
"non_first_name_prefixes", "suffix_acronyms_ambiguous",
538+
)
539+
#: v1's repr scalar order, minus empty_attribute_default -- removed in 2.0
540+
#: (#255), so there's no such attribute on this shim's Constants to show.
541+
_REPR_SCALAR_ATTRS = (
542+
"string_format", "initials_format", "initials_delimiter",
543+
"initials_separator", "suffix_delimiter",
544+
"capitalize_name", "force_mixed_case_capitalization",
545+
"patronymic_name_order", "middle_name_as_last",
546+
)
547+
379548
_SCALAR_DEFAULTS: dict[str, object] = {
380549
"patronymic_name_order": False,
381550
"middle_name_as_last": False,
@@ -397,16 +566,6 @@ def _raise_readonly(name: str) -> None:
397566
_UNSET_KWARG = object()
398567

399568

400-
def _reject_bare_str_for_field(value: object, field: str) -> None:
401-
# A bare string is an iterable of its characters, so e.g.
402-
# SetManager("dr") would silently shred it into {'d', 'r'} instead of
403-
# raising -- shared by every Constants() set-field kwarg (#238/#244).
404-
if isinstance(value, (str, bytes)):
405-
raise TypeError(
406-
f"{field} must be an iterable of strings, not a single "
407-
f"str/bytes: {value!r}; wrap it in a list, e.g. [{value!r}]"
408-
)
409-
410569
_SHARED_MUTATION_MESSAGE = (
411570
"mutating the shared CONSTANTS singleton is deprecated and will be "
412571
"removed in 3.0; build a Lexicon/Policy (or a private Constants "
@@ -554,13 +713,13 @@ def __init__(
554713
value = overrides[name]
555714
if value is _UNSET_KWARG:
556715
value = vocab[name]
557-
else:
558-
# a caller-supplied value REPLACES that field's default
559-
# vocabulary wholesale (v1 parity) -- validated/normalized
560-
# by SetManager below, once past the bare-str guard
561-
_reject_bare_str_for_field(value, name)
716+
# a caller-supplied value REPLACES that field's default
717+
# vocabulary wholesale (v1 parity); SetManager itself validates/
718+
# normalizes and rejects a bare str/bytes (#238/#241), naming
719+
# this field in the message via _field=
562720
object.__setattr__(
563-
self, name, SetManager(value, _on_change=self._bump)) # type: ignore[arg-type]
721+
self, name,
722+
SetManager(value, _on_change=self._bump, _field=name)) # type: ignore[arg-type]
564723
if capitalization_exceptions is _UNSET_KWARG:
565724
from nameparser.config.capitalization import (
566725
CAPITALIZATION_EXCEPTIONS,
@@ -626,8 +785,10 @@ def __setattr__(self, name: str, value: object) -> None:
626785
"flags"
627786
)
628787
if name in _SET_FIELDS:
629-
# v1 allowed wholesale reassignment (c.titles = {...})
630-
value = SetManager(value, _on_change=self._bump) # type: ignore[arg-type]
788+
# v1 allowed wholesale reassignment (c.titles = {...}); same
789+
# bare-str/bytes guard as the constructor kwarg path (#238/#241)
790+
value = SetManager(
791+
value, _on_change=self._bump, _field=name) # type: ignore[arg-type]
631792
elif name == "capitalization_exceptions":
632793
value = TupleManager(value, _on_change=self._bump) # type: ignore[arg-type]
633794
elif name in ("nickname_delimiters", "maiden_delimiters"):
@@ -652,8 +813,15 @@ def __setattr__(self, name: str, value: object) -> None:
652813
def copy(self) -> Constants: # #260
653814
# An independent instance with its own generation counter and
654815
# its own manager callbacks -- not a shared-state alias like a
655-
# naive attribute-for-attribute copy would produce.
656-
new = Constants()
816+
# naive attribute-for-attribute copy would produce. v1's copy()
817+
# was `copy.deepcopy(self)`, which builds the new object via
818+
# `type(self).__new__(type(self))` -- NOT by calling `type(self)()`
819+
# -- so a Constants subclass copies as itself without its __init__
820+
# running (and without needing to satisfy whatever signature that
821+
# __init__ might require). Mirrored here with an explicit __new__
822+
# bypass rather than type(self)().
823+
new = object.__new__(type(self))
824+
object.__setattr__(new, "_generation", 0)
657825
for name in _SET_FIELDS:
658826
object.__setattr__(
659827
new, name,
@@ -663,10 +831,26 @@ def copy(self) -> Constants: # #260
663831
for bucket in ("nickname_delimiters", "maiden_delimiters"):
664832
object.__setattr__(new, bucket, _DelimiterManager(
665833
dict(getattr(self, bucket)), _on_change=new._bump))
834+
object.__setattr__(new, "regexes", _RegexesProxy())
666835
for name in _SCALAR_DEFAULTS:
667836
object.__setattr__(new, name, getattr(self, name))
668837
return new
669838

839+
def __repr__(self) -> str: # #221
840+
# Collections (some with hundreds of entries, e.g. titles/prefixes)
841+
# are summarized as counts rather than dumped in full, like v1.
842+
# Scalars are only shown when they differ from the library default
843+
# -- _SCALAR_DEFAULTS stands in for v1's `getattr(type(self), name)`
844+
# class-level default, since this shim's scalar defaults are
845+
# instance attributes set in __init__, not class attributes.
846+
lines = [f" {name}: {len(getattr(self, name))}"
847+
for name in _REPR_COLLECTION_ATTRS]
848+
lines += [
849+
f" {name}: {value!r}" for name in _REPR_SCALAR_ATTRS
850+
if (value := getattr(self, name)) != _SCALAR_DEFAULTS[name]
851+
]
852+
return "<Constants : [\n" + "\n".join(lines) + "\n]>"
853+
670854
# -- snapshot -----------------------------------------------------------
671855

672856
def _snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:

0 commit comments

Comments
 (0)