Skip to content

Commit 605e31f

Browse files
derek73claude
andauthored
Fix bare string to SetManager silently shredding into characters (#240)
* Fix bare string to SetManager silently shredding into characters Every set-backed Constants argument is annotated Iterable[str], which a bare str satisfies — as an iterable of its characters. SetManager's set(elements) then silently replaced the whole default set with single characters (Constants(titles='dr') -> titles == {'d', 'r'}), producing wrong parses with no error anywhere. The type system cannot catch this, so it must be a runtime check. Reject str and bytes in SetManager.__init__ with a TypeError that includes the wrap-it-in-a-list fix, covering all nine set-backed Constants arguments and direct SetManager construction in one place. All internal call sites pass module-level tuples/sets, and pickling restores SetManager by state without re-running __init__, so only the buggy input path is affected. Closes #238 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> * 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> * 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> * Simplify and speed up SetManager normalization paths The operand-normalization work left a compounding perf regression: operators delegated to the ABC mixins, whose _from_iterable re-entered __init__ and re-validated the entire result element by element, and Constants() re-checked ~1,400 already-normalized default constants on every construction. Measured on the headline per-instance-config path, HumanName(constants=None) was 6.8x slower than master (101 -> 690us) and Constants() 21.6x slower (7.5 -> 162us). Build operator results with plain set ops on already-normalized elements via a private _from_normalized constructor (also dropping the super()-delegation type ignores), short-circuit SetManager operands in _normalized_elements (a SetManager is normalized by construction), and validate the nine default config sets once at import, copied on identity-checked defaults. Back to master parity: Constants() 8.2us, HumanName(constants=None) 58us. Also fold the single-caller _reject_bare_string into _normalized_elements, note the deliberate bytes-policy divergence from add_with_encoding(), merge two tests that pinned the same |= parse end-to-end, trim the triple-stated operator rationale, and update the stale AGENTS.md SetManager normalization note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review follow-ups: document mutation caveat, harden _from_normalized docs, add tests - Document that the _DEFAULT_* identity fast path in Constants.__init__ snapshots module constants at import time, so mutating a raw constant (e.g. TITLES.add(...)) after import isn't picked up by later Constants() instances — only the documented CONSTANTS.titles.add(...) path is supported. - Strengthen _from_normalized's docstring to state explicitly that it performs no validation and callers must pre-normalize elements. - Add tests pinning __rsub__'s operand order, confirming Constants() instances don't alias mutable state via the identity fast path, and confirming an equal-but-not-identical titles list still validates fully instead of false-matching the fast path. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 192d35e commit 605e31f

4 files changed

Lines changed: 250 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Most modules define a plain Python set of known name pieces; `capitalization.py`
8585

8686
`config/__init__.py` wraps everything into `SetManager` and `TupleManager` instances inside a `Constants` class. A module-level singleton `CONSTANTS` is shared across all `HumanName` instances by default.
8787

88-
**Two-tier config pattern**: `CONSTANTS` is global; passing `None` as the second arg to `HumanName` creates a fresh per-instance `Constants()`. After modifying per-instance config you must call `hn.parse_full_name()` again. `SetManager.add()`/`remove()` normalizes inputs to lowercase with no periods, so callers don't need to worry about case.
88+
**Two-tier config pattern**: `CONSTANTS` is global; passing `None` as the second arg to `HumanName` creates a fresh per-instance `Constants()`. After modifying per-instance config you must call `hn.parse_full_name()` again. `SetManager` normalizes elements (lowercase, leading/trailing periods stripped) at every entry point — constructor, `add()`/`remove()`, and set operators — and raises `TypeError` for bare strings/bytes and non-str elements, so callers don't need to worry about case.
8989

9090
**`_CachedUnionMember` descriptor**: The four PST-contributing attrs (`prefixes`, `suffix_acronyms`, `suffix_not_acronyms`, `titles`) are managed by this descriptor, which stores their values under the *private* name (`_prefixes`, `_titles`, etc.) in the instance `__dict__` so that the descriptor's `__set__` owns every assignment and can wire the cache-invalidation callback. Any code that inspects `__dict__` directly (e.g. `__getstate__`) must map `_xxx``xxx` for descriptor-managed attrs rather than filtering on `not k.startswith('_')`.
9191

docs/release_log.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
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')``), 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 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
46
- 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)
57
- 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)
68
- 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: 133 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,77 @@ 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 (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.
6368
6469
'''
6570

6671
_on_change: Callable[[], None] | None
6772

73+
@classmethod
74+
def _normalized_elements(cls, elements: Iterable[str]) -> set[str]:
75+
# a SetManager's elements were validated and normalized when it was
76+
# built, so copy them instead of re-validating — this is what keeps
77+
# chained unions (suffixes_prefixes_titles) and default Constants()
78+
# construction from re-checking ~1,400 entries per step
79+
if isinstance(elements, SetManager):
80+
return set(elements.elements)
81+
# a bare string is an iterable of its characters, so set(value)
82+
# would silently build a set of single characters — and bytes
83+
# iterates to ints, which can never match parsed tokens (#238)
84+
if isinstance(elements, bytes):
85+
raise TypeError(
86+
"expected an iterable of strings, got a single bytes; "
87+
f"decode it first: [{elements!r}.decode()]"
88+
)
89+
if isinstance(elements, str):
90+
raise TypeError(
91+
"expected an iterable of strings, got a single str; "
92+
f"wrap it in a list: [{elements!r}]"
93+
)
94+
# apply the same lc() normalization (lowercase, strip leading/
95+
# trailing periods) that add() applies, and reject junk elements:
96+
# lc() on bytes or int crashes without naming the culprit, and
97+
# lc(None) silently transmutes to ''. Divergence from add() is
98+
# deliberate: add_with_encoding() decodes bytes for back-compat,
99+
# bulk boundaries stay strict.
100+
normalized = set()
101+
for s in elements:
102+
if isinstance(s, bytes):
103+
raise TypeError(
104+
f"expected str elements, got bytes; decode it first: {s!r}.decode()"
105+
)
106+
if not isinstance(s, str):
107+
raise TypeError(
108+
f"expected str elements, got {type(s).__name__}: {s!r}"
109+
)
110+
normalized.add(lc(s))
111+
return normalized
112+
113+
@classmethod
114+
def _from_normalized(cls, elements: set[str]) -> 'SetManager':
115+
# Private fast constructor: bypasses __init__ so results aren't
116+
# re-validated element by element. This performs NO validation or
117+
# normalization of `elements` -- the caller is fully responsible
118+
# for guaranteeing every element is already a str that has passed
119+
# through lc(). Only call this with a set built from other
120+
# SetManagers' already-normalized .elements (operator results,
121+
# prebuilt default copies); passing anything else silently defeats
122+
# the constructor's #238 guarantees with no error raised here.
123+
obj = cls.__new__(cls)
124+
obj.elements = elements
125+
obj._on_change = None
126+
return obj
127+
68128
def __init__(self, elements: Iterable[str]) -> None:
69-
self.elements = set(elements)
129+
self.elements = self._normalized_elements(elements)
70130
# Optional invalidation hook, wired by an owning Constants so that
71131
# in-place add()/remove() can clear its cached suffixes_prefixes_titles
72132
# union. None when the manager is used standalone.
@@ -90,6 +150,36 @@ def __contains__(self, value: object) -> bool:
90150
def __len__(self) -> int:
91151
return len(self.elements)
92152

153+
# The ABC mixins compare raw operand elements against stored (normalized)
154+
# ones, and their __or__/__and__ accept a bare str as Iterable, so every
155+
# operand is validated and normalized here. Results are built with plain
156+
# set ops on already-normalized elements instead of delegating to the
157+
# mixins, whose _from_iterable would re-validate the whole result
158+
# through __init__.
159+
#
160+
# the runtime ABC accepts any Iterable operand, so annotate honestly and
161+
# ignore typeshed's narrower AbstractSet declarations
162+
def __or__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
163+
return self._from_normalized(self.elements | self._normalized_elements(other))
164+
165+
__ror__ = __or__
166+
167+
def __and__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
168+
return self._from_normalized(self.elements & self._normalized_elements(other))
169+
170+
__rand__ = __and__
171+
172+
def __sub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
173+
return self._from_normalized(self.elements - self._normalized_elements(other))
174+
175+
def __rsub__(self, other: Iterable[str]) -> 'SetManager':
176+
return self._from_normalized(self._normalized_elements(other) - self.elements)
177+
178+
def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
179+
return self._from_normalized(self.elements ^ self._normalized_elements(other))
180+
181+
__rxor__ = __xor__
182+
93183
def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
94184
"""
95185
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
@@ -111,7 +201,7 @@ def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
111201
def add(self, *strings: str) -> Self:
112202
"""
113203
Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set.
114-
Can pass a list of strings. Returns ``self`` for chaining.
204+
Returns ``self`` for chaining.
115205
"""
116206
for s in strings:
117207
self.add_with_encoding(s)
@@ -153,6 +243,31 @@ def _is_dunder(attr: str) -> bool:
153243
return attr.startswith("__") and attr.endswith("__")
154244

155245

246+
# The default config sets are module constants that never change, so
247+
# validate and normalize each one exactly once at import. Constants()
248+
# copies these via _normalized_elements' SetManager fast path instead of
249+
# re-checking ~1,400 elements per construction — a cost that otherwise
250+
# repeats on the per-instance-config path, HumanName(constants=None).
251+
#
252+
# This snapshot is taken once, at import time: mutating a raw constant
253+
# (e.g. `TITLES.add('x')`) after import is *not* picked up by Constants()
254+
# built afterward, since the identity check in Constants.__init__ reuses
255+
# this frozen SetManager rather than re-wrapping the (now-changed) raw
256+
# set. That's a behavior change from re-wrapping every time, but the
257+
# documented customization path mutates the SetManager wrapper on a
258+
# Constants instance (``CONSTANTS.titles.add(...)``), not the raw
259+
# constant, so this only affects an unsupported/undocumented pattern.
260+
_DEFAULT_PREFIXES = SetManager(PREFIXES)
261+
_DEFAULT_SUFFIX_ACRONYMS = SetManager(SUFFIX_ACRONYMS)
262+
_DEFAULT_SUFFIX_NOT_ACRONYMS = SetManager(SUFFIX_NOT_ACRONYMS)
263+
_DEFAULT_SUFFIX_ACRONYMS_AMBIGUOUS = SetManager(SUFFIX_ACRONYMS_AMBIGUOUS)
264+
_DEFAULT_TITLES = SetManager(TITLES)
265+
_DEFAULT_FIRST_NAME_TITLES = SetManager(FIRST_NAME_TITLES)
266+
_DEFAULT_CONJUNCTIONS = SetManager(CONJUNCTIONS)
267+
_DEFAULT_BOUND_FIRST_NAMES = SetManager(BOUND_FIRST_NAMES)
268+
_DEFAULT_NON_FIRST_NAME_PREFIXES = SetManager(NON_FIRST_NAME_PREFIXES)
269+
270+
156271
class TupleManager(dict[str, T]):
157272
'''
158273
A dictionary with dot.notation access. Subclass of ``dict``. Wraps the
@@ -487,15 +602,18 @@ def __init__(self,
487602
# These four descriptor assignments call _CachedUnionMember.__set__, which
488603
# calls _invalidate_pst() and establishes self._pst. They must come before
489604
# any read of suffixes_prefixes_titles.
490-
self.prefixes = SetManager(prefixes)
491-
self.suffix_acronyms = SetManager(suffix_acronyms)
492-
self.suffix_not_acronyms = SetManager(suffix_not_acronyms)
493-
self.titles = SetManager(titles)
494-
self.first_name_titles = SetManager(first_name_titles)
495-
self.conjunctions = SetManager(conjunctions)
496-
self.bound_first_names = SetManager(bound_first_names)
497-
self.non_first_name_prefixes = SetManager(non_first_name_prefixes)
498-
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
605+
# untouched defaults (identity check) copy the prebuilt module-level
606+
# managers instead of re-validating the raw constants element by
607+
# element; user-supplied iterables still get the full check
608+
self.prefixes = SetManager(_DEFAULT_PREFIXES if prefixes is PREFIXES else prefixes)
609+
self.suffix_acronyms = SetManager(_DEFAULT_SUFFIX_ACRONYMS if suffix_acronyms is SUFFIX_ACRONYMS else suffix_acronyms)
610+
self.suffix_not_acronyms = SetManager(_DEFAULT_SUFFIX_NOT_ACRONYMS if suffix_not_acronyms is SUFFIX_NOT_ACRONYMS else suffix_not_acronyms)
611+
self.titles = SetManager(_DEFAULT_TITLES if titles is TITLES else titles)
612+
self.first_name_titles = SetManager(_DEFAULT_FIRST_NAME_TITLES if first_name_titles is FIRST_NAME_TITLES else first_name_titles)
613+
self.conjunctions = SetManager(_DEFAULT_CONJUNCTIONS if conjunctions is CONJUNCTIONS else conjunctions)
614+
self.bound_first_names = SetManager(_DEFAULT_BOUND_FIRST_NAMES if bound_first_names is BOUND_FIRST_NAMES else bound_first_names)
615+
self.non_first_name_prefixes = SetManager(_DEFAULT_NON_FIRST_NAME_PREFIXES if non_first_name_prefixes is NON_FIRST_NAME_PREFIXES else non_first_name_prefixes)
616+
self.suffix_acronyms_ambiguous = SetManager(_DEFAULT_SUFFIX_ACRONYMS_AMBIGUOUS if suffix_acronyms_ambiguous is SUFFIX_ACRONYMS_AMBIGUOUS else suffix_acronyms_ambiguous)
499617
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
500618
self.regexes = RegexTupleManager(regexes)
501619
# Per-bucket delimiter collections that parse_nicknames() consults to

0 commit comments

Comments
 (0)