Skip to content

Commit e36f454

Browse files
derek73claude
andcommitted
Address multi-agent review findings on PR #286
- Fix conftest.py's empty_attribute_default fixture teardown: the DeprecationWarning-ignore filter wrapped the restore of all 8 scalar config attrs instead of just the one deprecated assignment, which would have silently swallowed any future deprecation warning added to the other 7 -- a silent-failure hunter finding. Scoped to match the already-narrow setup block. - Add a type check to _EmptyAttributeDefaultAttribute.__set__: a cheap, immediate TypeError for non-str/non-None values instead of deferring the whole invariant to 2.0 (type-design-analyzer finding). - Document why __get__(obj=None) returns '' rather than `self` (unlike its _SetManagerAttribute sibling): it's load-bearing for Constants.__repr__'s default-value comparison, not just mypy inference (type-design-analyzer finding). - Fix a stale "~8 public str-typed properties" comment (actually 12) in two test files (comment-analyzer finding). - Add tests: Constants.copy() preserves a customized empty_attribute_default without warning; add_with_encoding()'s str path emits exactly one warning, not two; the legacy-pickle warning fires once per __setstate__ call even with two stale keys, not once per key (test-analyzer findings). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent dce8204 commit e36f454

4 files changed

Lines changed: 78 additions & 9 deletions

File tree

nameparser/config/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,12 +556,22 @@ def __get__(self, obj: 'Constants | None', objtype: type | None = None) -> str:
556556
# Annotated `str`, not `str | None`, to match the pre-descriptor
557557
# plain-attribute inference: None is documented/supported (see the
558558
# class docstring), but typing it honestly cascades `| None`
559-
# through ~8 public str-typed properties (title, first, ... ).
559+
# through every public str-typed name accessor (title, first, ...).
560+
# Returning '' rather than `self` on class access (unlike
561+
# _SetManagerAttribute, which returns `self`) is also load-bearing
562+
# for Constants.__repr__'s `getattr(type(self), name)` default
563+
# comparison in _repr_scalar_attrs -- returning `self` there would
564+
# make every Constants() show this attribute as "customized".
560565
if obj is None:
561566
return ''
562567
return getattr(obj, self._attr, '')
563568

564569
def __set__(self, obj: 'Constants', value: str | None) -> None:
570+
if value is not None and not isinstance(value, str):
571+
raise TypeError(
572+
f"empty_attribute_default must be a str or None, got "
573+
f"{type(value).__name__!r}"
574+
)
565575
warnings.warn(
566576
"Assigning Constants.empty_attribute_default is deprecated and "
567577
"will raise TypeError in 2.0; empty attributes will always "

tests/conftest.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,12 @@ def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | No
7171
warnings.simplefilter("ignore", DeprecationWarning)
7272
CONSTANTS.empty_attribute_default = request.param
7373
yield request.param
74-
with warnings.catch_warnings():
75-
warnings.simplefilter("ignore", DeprecationWarning)
76-
for attr, value in scalar_snapshot.items():
74+
for attr, value in scalar_snapshot.items():
75+
if attr == "empty_attribute_default":
76+
with warnings.catch_warnings():
77+
warnings.simplefilter("ignore", DeprecationWarning)
78+
setattr(CONSTANTS, attr, value)
79+
else:
7780
setattr(CONSTANTS, attr, value)
7881
for attr, value in collection_snapshot.items():
7982
setattr(CONSTANTS, attr, value)

tests/test_constants.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,16 @@ def test_empty_attribute_default_assignment_emits_deprecation_warning(self) -> N
309309
c.empty_attribute_default = None # type: ignore[assignment]
310310
self.assertIsNone(c.empty_attribute_default)
311311

312+
def test_empty_attribute_default_rejects_non_str_non_none(self) -> None:
313+
# A cheap early check on the invariant 2.0 will fully enforce (only
314+
# '' will be legal): non-str/non-None values fail loudly here
315+
# instead of surfacing later as a confusing failure deep in some
316+
# unrelated HumanName string property.
317+
c = Constants()
318+
with pytest.raises(TypeError, match="str or None"):
319+
c.empty_attribute_default = 42 # type: ignore[assignment]
320+
self.assertEqual(c.empty_attribute_default, '')
321+
312322
def test_empty_attribute_default_read_does_not_warn(self) -> None:
313323
c = Constants()
314324
with warnings.catch_warnings():
@@ -321,8 +331,9 @@ def test_empty_attribute_default(self) -> None:
321331
# from the '' default), but None is documented/supported here -- see
322332
# the doctest on the attribute's docstring in config/__init__.py.
323333
# Not widened to str | None like string_format/suffix_delimiter
324-
# because it cascades into ~8 public str-typed properties (title,
325-
# first, middle, last, suffix, nickname, initials()).
334+
# because it cascades into every public str-typed name accessor
335+
# (title, first, middle, last, suffix, nickname, maiden, surnames,
336+
# given_names, last_base, last_prefixes, initials()).
326337
with pytest.deprecated_call():
327338
CONSTANTS.empty_attribute_default = None # type: ignore[assignment]
328339
hn = HumanName("")
@@ -369,8 +380,14 @@ def test_add_with_encoding_str_emits_deprecation_warning(self) -> None:
369380
# (#263/#245); the str path is otherwise silent, so this method's
370381
# own removal is unwarned without this
371382
sm = SetManager(['dr'])
372-
with pytest.deprecated_call(match="add\\(\\)"):
383+
with warnings.catch_warnings(record=True) as caught:
384+
warnings.simplefilter("always")
373385
sm.add_with_encoding('esq')
386+
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
387+
# Exactly one -- the str path must not also trip the bytes-decode
388+
# warning (that one's for the *other* argument type).
389+
self.assertEqual(len(deprecations), 1)
390+
self.assertIn('add()', str(deprecations[0].message))
374391
self.assertIn('esq', sm)
375392

376393
def test_add_with_encoding_bytes_emits_two_distinct_warnings(self) -> None:
@@ -531,6 +548,30 @@ def test_unpickle_legacy_state_emits_deprecation_warning_once(self) -> None:
531548
deprecations = [w for w in record.list if issubclass(w.category, DeprecationWarning)]
532549
self.assertEqual(len(deprecations), 1)
533550

551+
def test_unpickle_legacy_state_with_two_stale_property_keys_warns_once(self) -> None:
552+
# Pins "once per __setstate__ call, not once per skipped key": with
553+
# only one computed property (suffixes_prefixes_titles) on the real
554+
# Constants class, the test above can't distinguish the two
555+
# semantics. A second read-only property on a throwaway subclass
556+
# forces two keys through the skip branch in one __setstate__ call.
557+
class ConstantsWithExtraProperty(Constants):
558+
@property
559+
def another_computed_property(self) -> str:
560+
return 'computed'
561+
562+
c = ConstantsWithExtraProperty()
563+
legacy_state = {
564+
name: getattr(c, name) for name in dir(c) if not name.startswith('_')
565+
}
566+
self.assertIn('suffixes_prefixes_titles', legacy_state)
567+
self.assertIn('another_computed_property', legacy_state)
568+
569+
restored = ConstantsWithExtraProperty.__new__(ConstantsWithExtraProperty)
570+
with pytest.deprecated_call(match="re-pickle") as record:
571+
restored.__setstate__(legacy_state)
572+
deprecations = [w for w in record.list if issubclass(w.category, DeprecationWarning)]
573+
self.assertEqual(len(deprecations), 1)
574+
534575
def test_setstate_without_legacy_keys_does_not_warn(self) -> None:
535576
c = Constants()
536577
c.titles.add('legacytitle')
@@ -1134,6 +1175,20 @@ class CustomConstants(Constants):
11341175
dup = c.copy()
11351176
self.assertTrue(isinstance(dup, CustomConstants))
11361177

1178+
def test_copy_preserves_empty_attribute_default_without_warning(self) -> None:
1179+
# copy() round-trips through __getstate__/__setstate__ like pickle
1180+
# does; restoring saved state isn't a user assignment, so it must not
1181+
# emit #255's deprecation warning (the __setstate__ bypass exists
1182+
# specifically for this call path, not just pickle.loads).
1183+
c = Constants()
1184+
with warnings.catch_warnings():
1185+
warnings.simplefilter("ignore", DeprecationWarning)
1186+
c.empty_attribute_default = None # type: ignore[assignment]
1187+
with warnings.catch_warnings():
1188+
warnings.simplefilter("error")
1189+
dup = c.copy()
1190+
self.assertIsNone(dup.empty_attribute_default)
1191+
11371192
def test_copy_snapshots_current_customizations(self) -> None:
11381193
# Unlike Constants(), which always starts from library defaults,
11391194
# .copy() preserves whatever customizations the original already has.

tests/test_initials.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ def test_initials_empty_part_with_none_default_not_literal_none(self) -> None:
3232
# from the '' default), but None is documented/supported here -- see
3333
# the doctest on the attribute's docstring in config/__init__.py. Not
3434
# widened to str | None like string_format/suffix_delimiter because
35-
# it cascades into ~8 public str-typed properties (title, first,
36-
# middle, last, suffix, nickname, initials()).
35+
# it cascades into every public str-typed name accessor (title,
36+
# first, middle, last, suffix, nickname, maiden, surnames,
37+
# given_names, last_base, last_prefixes, initials()).
3738
with pytest.deprecated_call():
3839
hn.C.empty_attribute_default = None # type: ignore[assignment]
3940
self.assertEqual(hn.initials(), "J. D.")

0 commit comments

Comments
 (0)