Skip to content

Commit fffd36d

Browse files
derek73claude
andcommitted
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>
1 parent de85734 commit fffd36d

3 files changed

Lines changed: 69 additions & 45 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

nameparser/config/__init__.py

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -70,29 +70,33 @@ class SetManager(Set):
7070

7171
_on_change: Callable[[], None] | None
7272

73-
@staticmethod
74-
def _reject_bare_string(value: object) -> None:
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)
7581
# a bare string is an iterable of its characters, so set(value)
7682
# would silently build a set of single characters — and bytes
7783
# iterates to ints, which can never match parsed tokens (#238)
78-
if isinstance(value, bytes):
84+
if isinstance(elements, bytes):
7985
raise TypeError(
8086
"expected an iterable of strings, got a single bytes; "
81-
f"decode it first: [{value!r}.decode()]"
87+
f"decode it first: [{elements!r}.decode()]"
8288
)
83-
if isinstance(value, str):
89+
if isinstance(elements, str):
8490
raise TypeError(
8591
"expected an iterable of strings, got a single str; "
86-
f"wrap it in a list: [{value!r}]"
92+
f"wrap it in a list: [{elements!r}]"
8793
)
88-
89-
@classmethod
90-
def _normalized_elements(cls, elements: Iterable[str]) -> set[str]:
9194
# apply the same lc() normalization (lowercase, strip leading/
9295
# trailing periods) that add() applies, and reject junk elements:
9396
# lc() on bytes or int crashes without naming the culprit, and
94-
# lc(None) silently transmutes to ''
95-
cls._reject_bare_string(elements)
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.
96100
normalized = set()
97101
for s in elements:
98102
if isinstance(s, bytes):
@@ -106,6 +110,16 @@ def _normalized_elements(cls, elements: Iterable[str]) -> set[str]:
106110
normalized.add(lc(s))
107111
return normalized
108112

113+
@classmethod
114+
def _from_normalized(cls, elements: set[str]) -> 'SetManager':
115+
# private fast constructor for sets this class already normalized
116+
# (operator results, prebuilt default copies); bypasses __init__
117+
# so results aren't re-validated element by element
118+
obj = cls.__new__(cls)
119+
obj.elements = elements
120+
obj._on_change = None
121+
return obj
122+
109123
def __init__(self, elements: Iterable[str]) -> None:
110124
self.elements = self._normalized_elements(elements)
111125
# Optional invalidation hook, wired by an owning Constants so that
@@ -131,36 +145,33 @@ def __contains__(self, value: object) -> bool:
131145
def __len__(self) -> int:
132146
return len(self.elements)
133147

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'.
148+
# The ABC mixins compare raw operand elements against stored (normalized)
149+
# ones, and their __or__/__and__ accept a bare str as Iterable, so every
150+
# operand is validated and normalized here. Results are built with plain
151+
# set ops on already-normalized elements instead of delegating to the
152+
# mixins, whose _from_iterable would re-validate the whole result
153+
# through __init__.
142154
#
143155
# the runtime ABC accepts any Iterable operand, so annotate honestly and
144156
# ignore typeshed's narrower AbstractSet declarations
145157
def __or__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
146-
return super().__or__(self._normalized_elements(other)) # type: ignore[operator, return-value]
158+
return self._from_normalized(self.elements | self._normalized_elements(other))
147159

148160
__ror__ = __or__
149161

150162
def __and__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
151-
return super().__and__(self._normalized_elements(other)) # type: ignore[operator, return-value]
163+
return self._from_normalized(self.elements & self._normalized_elements(other))
152164

153165
__rand__ = __and__
154166

155167
def __sub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
156-
return super().__sub__(self._normalized_elements(other)) # type: ignore[operator, return-value]
168+
return self._from_normalized(self.elements - self._normalized_elements(other))
157169

158-
def __rsub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
159-
# typeshed omits Set.__rsub__, but the runtime ABC defines it
160-
return super().__rsub__(self._normalized_elements(other)) # type: ignore[misc, operator, return-value]
170+
def __rsub__(self, other: Iterable[str]) -> 'SetManager':
171+
return self._from_normalized(self._normalized_elements(other) - self.elements)
161172

162173
def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override]
163-
return super().__xor__(self._normalized_elements(other)) # type: ignore[operator, return-value]
174+
return self._from_normalized(self.elements ^ self._normalized_elements(other))
164175

165176
__rxor__ = __xor__
166177

@@ -227,6 +238,22 @@ def _is_dunder(attr: str) -> bool:
227238
return attr.startswith("__") and attr.endswith("__")
228239

229240

241+
# The default config sets are module constants that never change, so
242+
# validate and normalize each one exactly once at import. Constants()
243+
# copies these via _normalized_elements' SetManager fast path instead of
244+
# re-checking ~1,400 elements per construction — a real cost on the
245+
# per-instance-config path, HumanName(constants=None).
246+
_DEFAULT_PREFIXES = SetManager(PREFIXES)
247+
_DEFAULT_SUFFIX_ACRONYMS = SetManager(SUFFIX_ACRONYMS)
248+
_DEFAULT_SUFFIX_NOT_ACRONYMS = SetManager(SUFFIX_NOT_ACRONYMS)
249+
_DEFAULT_SUFFIX_ACRONYMS_AMBIGUOUS = SetManager(SUFFIX_ACRONYMS_AMBIGUOUS)
250+
_DEFAULT_TITLES = SetManager(TITLES)
251+
_DEFAULT_FIRST_NAME_TITLES = SetManager(FIRST_NAME_TITLES)
252+
_DEFAULT_CONJUNCTIONS = SetManager(CONJUNCTIONS)
253+
_DEFAULT_BOUND_FIRST_NAMES = SetManager(BOUND_FIRST_NAMES)
254+
_DEFAULT_NON_FIRST_NAME_PREFIXES = SetManager(NON_FIRST_NAME_PREFIXES)
255+
256+
230257
class TupleManager(dict[str, T]):
231258
'''
232259
A dictionary with dot.notation access. Subclass of ``dict``. Wraps the
@@ -561,15 +588,18 @@ def __init__(self,
561588
# These four descriptor assignments call _CachedUnionMember.__set__, which
562589
# calls _invalidate_pst() and establishes self._pst. They must come before
563590
# any read of suffixes_prefixes_titles.
564-
self.prefixes = SetManager(prefixes)
565-
self.suffix_acronyms = SetManager(suffix_acronyms)
566-
self.suffix_not_acronyms = SetManager(suffix_not_acronyms)
567-
self.titles = SetManager(titles)
568-
self.first_name_titles = SetManager(first_name_titles)
569-
self.conjunctions = SetManager(conjunctions)
570-
self.bound_first_names = SetManager(bound_first_names)
571-
self.non_first_name_prefixes = SetManager(non_first_name_prefixes)
572-
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
591+
# untouched defaults (identity check) copy the prebuilt module-level
592+
# managers instead of re-validating the raw constants element by
593+
# element; user-supplied iterables still get the full check
594+
self.prefixes = SetManager(_DEFAULT_PREFIXES if prefixes is PREFIXES else prefixes)
595+
self.suffix_acronyms = SetManager(_DEFAULT_SUFFIX_ACRONYMS if suffix_acronyms is SUFFIX_ACRONYMS else suffix_acronyms)
596+
self.suffix_not_acronyms = SetManager(_DEFAULT_SUFFIX_NOT_ACRONYMS if suffix_not_acronyms is SUFFIX_NOT_ACRONYMS else suffix_not_acronyms)
597+
self.titles = SetManager(_DEFAULT_TITLES if titles is TITLES else titles)
598+
self.first_name_titles = SetManager(_DEFAULT_FIRST_NAME_TITLES if first_name_titles is FIRST_NAME_TITLES else first_name_titles)
599+
self.conjunctions = SetManager(_DEFAULT_CONJUNCTIONS if conjunctions is CONJUNCTIONS else conjunctions)
600+
self.bound_first_names = SetManager(_DEFAULT_BOUND_FIRST_NAMES if bound_first_names is BOUND_FIRST_NAMES else bound_first_names)
601+
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)
602+
self.suffix_acronyms_ambiguous = SetManager(_DEFAULT_SUFFIX_ACRONYMS_AMBIGUOUS if suffix_acronyms_ambiguous is SUFFIX_ACRONYMS_AMBIGUOUS else suffix_acronyms_ambiguous)
573603
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
574604
self.regexes = RegexTupleManager(regexes)
575605
# Per-bucket delimiter collections that parse_nicknames() consults to

tests/test_constants.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,12 @@ def test_set_manager_bare_string_operand_raises_typeerror(self) -> None:
7777
op()
7878

7979
def test_set_manager_operators_accept_lists(self) -> None:
80+
# end-to-end: an operator-built set wired back into Constants must
81+
# behave like an add()-built one, normalization included
8082
c = Constants()
8183
with pytest.raises(TypeError, match=r"wrap it in a list"):
8284
c.titles |= 'esq'
83-
c.titles |= ['esq']
85+
c.titles |= ['Esq.']
8486
self.assertIn('esq', c.titles)
8587
hn = HumanName("Esq Jane Smith", constants=c)
8688
self.m(hn.title, "Esq", hn)
@@ -128,14 +130,6 @@ def test_set_manager_non_str_elements_raise_typeerror(self) -> None:
128130
with pytest.raises(TypeError, match=r"expected str elements"):
129131
SetManager(['dr']) | [1] # type: ignore[list-item]
130132

131-
def test_set_manager_operator_result_parses_when_wired_into_constants(self) -> None:
132-
# end-to-end: an operator-built set must behave like an add()-built
133-
# one once assigned back to Constants
134-
c = Constants()
135-
c.titles |= ['Esq.']
136-
hn = HumanName("Esq Jane Smith", constants=c)
137-
self.m(hn.title, "Esq", hn)
138-
139133
def test_remove_title(self) -> None:
140134
hn = HumanName("Hon Solo", constants=None)
141135
start_len = len(hn.C.titles)

0 commit comments

Comments
 (0)