Skip to content

Commit 3c705d4

Browse files
committed
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.
1 parent fffd36d commit 3c705d4

2 files changed

Lines changed: 48 additions & 5 deletions

File tree

nameparser/config/__init__.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,14 @@ def _normalized_elements(cls, elements: Iterable[str]) -> set[str]:
112112

113113
@classmethod
114114
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
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.
118123
obj = cls.__new__(cls)
119124
obj.elements = elements
120125
obj._on_change = None
@@ -241,8 +246,17 @@ def _is_dunder(attr: str) -> bool:
241246
# The default config sets are module constants that never change, so
242247
# validate and normalize each one exactly once at import. Constants()
243248
# 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).
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.
246260
_DEFAULT_PREFIXES = SetManager(PREFIXES)
247261
_DEFAULT_SUFFIX_ACRONYMS = SetManager(SUFFIX_ACRONYMS)
248262
_DEFAULT_SUFFIX_NOT_ACRONYMS = SetManager(SUFFIX_NOT_ACRONYMS)

tests/test_constants.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from nameparser import HumanName
99
from nameparser.config import Constants, RegexTupleManager, SetManager, TupleManager
1010
from nameparser.config.regexes import EMPTY_REGEX
11+
from nameparser.config.titles import TITLES
1112

1213
from tests.base import HumanNameTestBase
1314

@@ -104,6 +105,15 @@ def test_set_manager_operators_normalize_like_add(self) -> None:
104105
# pins __rxor__ separately in case it ever stops aliasing __xor__
105106
self.assertEqual((['Dr.', 'Esq.'] ^ sm).elements, {'mr', 'esq'})
106107

108+
def test_set_manager_rsub_is_order_sensitive(self) -> None:
109+
# __sub__ and __rsub__ are hand-written separately (subtraction
110+
# isn't commutative, unlike |/&/^), so a copy-paste operand swap
111+
# in __rsub__ would silently flip the result and nothing else
112+
# in this file would catch it
113+
sm = SetManager(['dr', 'mr'])
114+
self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'})
115+
self.assertEqual((sm - ['Dr.', 'Esq.']).elements, {'mr'})
116+
107117
def test_set_manager_constructor_normalizes_like_add(self) -> None:
108118
# without constructor normalization the operators misfire against
109119
# the exact spelling visibly stored in the set: & returns empty
@@ -120,6 +130,25 @@ def test_constants_kwarg_elements_are_normalized(self) -> None:
120130
hn = HumanName("Chemistry Jane Smith", constants=c)
121131
self.m(hn.title, "Chemistry", hn)
122132

133+
def test_default_constants_construction_does_not_alias_defaults(self) -> None:
134+
# Constants() reuses the prebuilt _DEFAULT_TITLES snapshot via an
135+
# identity check instead of re-validating ~1,400 entries; if that
136+
# fast path ever returned the shared elements set instead of a
137+
# copy, mutating one Constants() instance would corrupt every
138+
# other instance's (and the module-level default's) titles
139+
c1 = Constants()
140+
c1.titles.add('zzz_should_not_leak')
141+
c2 = Constants()
142+
self.assertNotIn('zzz_should_not_leak', c2.titles)
143+
self.assertNotIn('zzz_should_not_leak', TITLES)
144+
145+
def test_equal_but_not_identical_titles_list_still_validates(self) -> None:
146+
# the fast path in Constants.__init__ is an `is` check against the
147+
# raw TITLES object, not `==`; an equal-but-copied list must still
148+
# go through full normalization rather than accidentally matching
149+
c = Constants(titles=list(TITLES) + ['Extra.'])
150+
self.assertIn('extra', c.titles)
151+
123152
def test_set_manager_non_str_elements_raise_typeerror(self) -> None:
124153
# lc() on junk elements either crashes context-free (bytes, int) or
125154
# silently transmutes None into '' — raise a curated error instead

0 commit comments

Comments
 (0)