2121
2222import functools
2323import warnings
24- from collections .abc import Callable , Iterable , Iterator , KeysView , Mapping
24+ from collections .abc import (
25+ Callable , Iterable , Iterator , KeysView , Mapping , Set ,
26+ )
2527from typing import NamedTuple , Self
2628
2729from nameparser ._lexicon import Lexicon
3032from 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+
3376class 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