diff --git a/src/attr/_make.py b/src/attr/_make.py index eb1cda9bb..27fe7e5fd 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -12,6 +12,7 @@ import sys import types import unicodedata +import weakref from collections.abc import Callable, Mapping from functools import cached_property @@ -103,6 +104,44 @@ def __reduce__(self, _none_constructor=type(None), _args=()): # noqa: B008 return _none_constructor, _args +class _Hashability(enum.Enum): + """ + The hashability of a class. + """ + + HASHABLE = "hashable" # write a __hash__ + UNHASHABLE = "unhashable" # set __hash__ to None + LEAVE_ALONE = "leave_alone" # don't touch __hash__ + + +class ClassProps(NamedTuple): + """ + Effective class properties as derived from parameters to attr.s() or + define() decorators. + + .. versionadded:: 25.4.0 + """ + + is_exception: bool + is_slotted: bool + has_weakref_slot: bool + is_frozen: bool + is_kw_only: bool + force_kw_only: bool + collect_by_mro: bool + init: bool + repr: bool + eq: bool + order: bool + hash: _Hashability + cache_hash: bool + match_args: bool + str: bool + getstate_setstate: bool + on_setattr: Callable[[str, Any], Any] + field_transformer: Callable[[Attribute], Attribute] + + def attrib( default=NOTHING, validator=None, @@ -673,40 +712,30 @@ def __init__( self, cls: type, these, - slots, - frozen, - weakref_slot, - getstate_setstate, - auto_attribs, - kw_only, - force_kw_only, - cache_hash, - is_exc, - collect_by_mro, - on_setattr, - has_custom_setattr, - field_transformer, + auto_attribs: bool, + props: ClassProps, + has_custom_setattr: bool, ): attrs, base_attrs, base_map = _transform_attrs( cls, these, auto_attribs, - kw_only, - force_kw_only, - collect_by_mro, - field_transformer, + props.is_kw_only, + props.force_kw_only, + props.collect_by_mro, + props.field_transformer, ) self._cls = cls - self._cls_dict = dict(cls.__dict__) if slots else {} + self._cls_dict = dict(cls.__dict__) if props.is_slotted else {} self._attrs = attrs self._base_names = {a.name for a in base_attrs} self._base_attr_map = base_map self._attr_names = tuple(a.name for a in attrs) - self._slots = slots - self._frozen = frozen - self._weakref_slot = weakref_slot - self._cache_hash = cache_hash + self._slots = props.is_slotted + self._frozen = props.is_frozen + self._weakref_slot = props.has_weakref_slot + self._cache_hash = props.cache_hash self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) self._pre_init_has_args = False if self._has_pre_init: @@ -717,20 +746,21 @@ def __init__( self._pre_init_has_args = len(pre_init_signature.parameters) > 1 self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) self._delete_attribs = not bool(these) - self._is_exc = is_exc - self._on_setattr = on_setattr + self._is_exc = props.is_exception + self._on_setattr = props.on_setattr self._has_custom_setattr = has_custom_setattr self._wrote_own_setattr = False self._cls_dict["__attrs_attrs__"] = self._attrs + self._cls_dict["__attrs_props__"] = props - if frozen: + if props.is_frozen: self._cls_dict["__setattr__"] = _frozen_setattrs self._cls_dict["__delattr__"] = _frozen_delattrs self._wrote_own_setattr = True - elif on_setattr in ( + elif self._on_setattr in ( _DEFAULT_ON_SETATTR, setters.validate, setters.convert, @@ -746,18 +776,18 @@ def __init__( break if ( ( - on_setattr == _DEFAULT_ON_SETATTR + self._on_setattr == _DEFAULT_ON_SETATTR and not (has_validator or has_converter) ) - or (on_setattr == setters.validate and not has_validator) - or (on_setattr == setters.convert and not has_converter) + or (self._on_setattr == setters.validate and not has_validator) + or (self._on_setattr == setters.convert and not has_converter) ): # If class-level on_setattr is set to convert + validate, but # there's no field to convert or validate, pretend like there's # no on_setattr. self._on_setattr = None - if getstate_setstate: + if props.getstate_setstate: ( self._cls_dict["__getstate__"], self._cls_dict["__setstate__"], @@ -808,6 +838,7 @@ def build_class(self): self._eval_snippets() if self._slots is True: cls = self._create_slots_class() + self._cls.__attrs_base_of_slotted__ = weakref.ref(cls) else: cls = self._patch_original_class() if PY_3_10_PLUS: @@ -1456,6 +1487,7 @@ def attrs( on_setattr = setters.pipe(*on_setattr) def wrap(cls): + nonlocal hash is_frozen = frozen or _has_frozen_base_class(cls) is_exc = auto_exc is True and issubclass(cls, BaseException) has_own_setattr = auto_detect and _has_own_attribute( @@ -1466,85 +1498,98 @@ def wrap(cls): msg = "Can't freeze a class with a custom __setattr__." raise ValueError(msg) - builder = _ClassBuilder( - cls, - these, - slots, - is_frozen, - weakref_slot, - _determine_whether_to_implement( + eq = not is_exc and _determine_whether_to_implement( + cls, eq_, auto_detect, ("__eq__", "__ne__") + ) + + if is_exc: + hashability = _Hashability.LEAVE_ALONE + elif hash is True: + hashability = _Hashability.HASHABLE + elif hash is False: + hashability = _Hashability.LEAVE_ALONE + elif hash is None: + if auto_detect is True and _has_own_attribute(cls, "__hash__"): + hashability = _Hashability.LEAVE_ALONE + elif eq is True and is_frozen is True: + hashability = _Hashability.HASHABLE + elif eq is False: + hashability = _Hashability.LEAVE_ALONE + else: + hashability = _Hashability.UNHASHABLE + else: + msg = "Invalid value for hash. Must be True, False, or None." + raise TypeError(msg) + + if hashability is not _Hashability.HASHABLE and cache_hash: + msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." + raise TypeError(msg) + + props = ClassProps( + is_exception=is_exc, + is_frozen=is_frozen, + is_slotted=slots, + collect_by_mro=collect_by_mro, + init=_determine_whether_to_implement( + cls, init, auto_detect, ("__init__",) + ), + repr=_determine_whether_to_implement( + cls, repr, auto_detect, ("__repr__",) + ), + eq=eq, + order=not is_exc + and _determine_whether_to_implement( + cls, + order_, + auto_detect, + ("__lt__", "__le__", "__gt__", "__ge__"), + ), + hash=hashability, + match_args=match_args, + is_kw_only=kw_only, + force_kw_only=force_kw_only, + has_weakref_slot=weakref_slot, + cache_hash=cache_hash, + str=str, + getstate_setstate=_determine_whether_to_implement( cls, getstate_setstate, auto_detect, ("__getstate__", "__setstate__"), default=slots, ), - auto_attribs, - kw_only, - force_kw_only, - cache_hash, - is_exc, - collect_by_mro, - on_setattr, - has_own_setattr, - field_transformer, + on_setattr=on_setattr, + field_transformer=field_transformer, ) - if _determine_whether_to_implement( - cls, repr, auto_detect, ("__repr__",) - ): + builder = _ClassBuilder( + cls, + these, + auto_attribs=auto_attribs, + props=props, + has_custom_setattr=has_own_setattr, + ) + + if props.repr is True: builder.add_repr(repr_ns) - if str is True: + if props.str is True: builder.add_str() - eq = _determine_whether_to_implement( - cls, eq_, auto_detect, ("__eq__", "__ne__") - ) - if not is_exc and eq is True: + if props.eq is True: builder.add_eq() - if not is_exc and _determine_whether_to_implement( - cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__") - ): + if props.order is True: builder.add_order() if not frozen: builder.add_setattr() - nonlocal hash - if ( - hash is None - and auto_detect is True - and _has_own_attribute(cls, "__hash__") - ): - hash = False - - if hash is not True and hash is not False and hash is not None: - # Can't use `hash in` because 1 == True for example. - msg = "Invalid value for hash. Must be True, False, or None." - raise TypeError(msg) - - if hash is False or (hash is None and eq is False) or is_exc: - # Don't do anything. Should fall back to __object__'s __hash__ - # which is by id. - if cache_hash: - msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." - raise TypeError(msg) - elif hash is True or ( - hash is None and eq is True and is_frozen is True - ): - # Build a __hash__ if told so, or if it's safe. + if props.hash is _Hashability.HASHABLE: builder.add_hash() - else: - # Raise TypeError on attempts to hash. - if cache_hash: - msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." - raise TypeError(msg) + elif props.hash is _Hashability.UNHASHABLE: builder.make_unhashable() - if _determine_whether_to_implement( - cls, init, auto_detect, ("__init__",) - ): + if props.init: builder.add_init() else: builder.add_attrs_init() diff --git a/tests/test_make.py b/tests/test_make.py index 6e35c0384..e0dfab7b3 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -26,6 +26,7 @@ from attr._compat import PY_3_10_PLUS from attr._make import ( Attribute, + ClassProps, Factory, _AndValidator, _Attributes, @@ -34,6 +35,7 @@ _determine_attrib_eq_order, _determine_attrs_eq_order, _determine_whether_to_implement, + _Hashability, _transform_attrs, and_, fields, @@ -506,6 +508,87 @@ class C: assert "x" == C.__attrs_attrs__[0].name assert all(isinstance(a, Attribute) for a in C.__attrs_attrs__) + def test_sets_attrs_props(self): + """ + Sets the `__attrs_props__` class attribute with the effective decorator + properties. + """ + + @attr.s( + slots=True, + frozen=True, + repr=True, + eq=True, + order=True, + unsafe_hash=True, + init=True, + match_args=False, + kw_only=True, + auto_attribs=True, + cache_hash=True, + str=True, + ) + class C: + x: int = attr.ib() + + assert ( + ClassProps( + is_exception=False, + is_slotted=True, + is_frozen=True, + init=True, + repr=True, + eq=True, + order=True, + hash=_Hashability.HASHABLE, + match_args=False, + is_kw_only=True, + force_kw_only=True, + has_weakref_slot=True, + collect_by_mro=False, + cache_hash=True, + str=True, + getstate_setstate=True, + on_setattr=None, + field_transformer=None, + ) + == C.__attrs_props__ + ) + + def test_sets_attrs_props_defaults(self): + """ + Default values are derived in `__attrs_props__` when not specified in + the decorator. + """ + + @attr.s + class CDef: + x = attr.ib() + + assert ( + ClassProps( + is_exception=False, + is_slotted=False, + is_frozen=False, + init=True, + repr=True, + eq=True, + order=True, + hash=_Hashability.UNHASHABLE, + match_args=True, + is_kw_only=False, + force_kw_only=True, + has_weakref_slot=True, + collect_by_mro=False, + cache_hash=False, + str=False, + getstate_setstate=False, + on_setattr=None, + field_transformer=None, + ) + == CDef.__attrs_props__ + ) + def test_empty(self): """ No attributes, no problems. @@ -1929,18 +2012,27 @@ class C: C, None, True, - True, - False, - False, - False, - False, - False, - False, - False, - True, - None, - False, - None, + ClassProps( + is_exception=False, + is_slotted=True, + is_frozen=True, + init=True, + repr=True, + eq=True, + order=False, + hash=False, + match_args=True, + is_kw_only=False, + force_kw_only=False, + has_weakref_slot=False, + collect_by_mro=True, + cache_hash=False, + str=False, + getstate_setstate=True, + on_setattr=None, + field_transformer=None, + ), + has_custom_setattr=False, ) assert "<_ClassBuilder(cls=C)>" == repr(b) @@ -1956,19 +2048,28 @@ class C: b = _ClassBuilder( C, None, - True, - True, False, - False, - False, - False, - False, - False, - False, - True, - None, - False, - None, + ClassProps( + is_exception=False, + is_slotted=True, + is_frozen=True, + init=True, + repr=True, + eq=True, + order=False, + hash=False, + match_args=True, + is_kw_only=False, + force_kw_only=False, + has_weakref_slot=False, + collect_by_mro=True, + cache_hash=False, + str=False, + getstate_setstate=True, + on_setattr=None, + field_transformer=None, + ), + has_custom_setattr=False, ) cls = ( @@ -2050,19 +2151,28 @@ def our_hasattr(obj, name, /) -> bool: b = _ClassBuilder( C, these=None, - slots=False, - frozen=False, - weakref_slot=True, - getstate_setstate=False, auto_attribs=False, - is_exc=False, - kw_only=False, - force_kw_only=False, - cache_hash=False, - collect_by_mro=True, - on_setattr=None, + props=ClassProps( + is_exception=False, + is_slotted=False, + is_frozen=False, + init=True, + repr=True, + eq=True, + order=False, + hash=False, + match_args=True, + is_kw_only=False, + force_kw_only=False, + has_weakref_slot=True, + collect_by_mro=True, + cache_hash=False, + str=False, + getstate_setstate=True, + on_setattr=None, + field_transformer=None, + ), has_custom_setattr=False, - field_transformer=None, ) def fake_meth(self): diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py index 41e534df0..3b9b2c34f 100644 --- a/tests/test_next_gen.py +++ b/tests/test_next_gen.py @@ -15,6 +15,7 @@ import attrs from attr._compat import PY_3_11_PLUS +from attr._make import ClassProps, _Hashability @attrs.define @@ -448,6 +449,90 @@ def test_smoke(self): ) +class TestProps: + """ + Tests for __attrs_props__ in define-style classes. + """ + + def test_define_props_custom(self): + """ + define() sets __attrs_props__ with custom parameters. + """ + + @attrs.define( + slots=False, + frozen=True, + order=True, + unsafe_hash=True, + init=True, + repr=True, + eq=True, + match_args=False, + kw_only=True, + cache_hash=True, + str=True, + ) + class C: + x: int + + assert ( + ClassProps( + is_exception=False, + is_slotted=False, + is_frozen=True, + is_kw_only=True, + force_kw_only=False, + init=True, + repr=True, + eq=True, + order=True, + hash=_Hashability.HASHABLE, + match_args=False, + has_weakref_slot=True, + collect_by_mro=True, + cache_hash=True, + str=True, + getstate_setstate=False, # because slots=False + on_setattr=None, + field_transformer=None, + ) + == C.__attrs_props__ + ) + + def test_define_props_defaults(self): + """ + frozen() sets default __attrs_props__ values. + """ + + @attrs.frozen + class C: + x: int + + assert ( + ClassProps( + is_exception=False, + is_slotted=True, + is_frozen=True, + init=True, + repr=True, + eq=True, + order=False, + hash=_Hashability.HASHABLE, # b/c frozen + match_args=True, + is_kw_only=False, + force_kw_only=False, + has_weakref_slot=True, + collect_by_mro=True, + cache_hash=False, + str=False, + getstate_setstate=True, + on_setattr=None, + field_transformer=None, + ) + == C.__attrs_props__ + ) + + class TestImports: """ Verify our re-imports and mirroring works. diff --git a/tests/test_slots.py b/tests/test_slots.py index 858a3b8b1..a74c32b03 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -104,6 +104,20 @@ def test_slots_being_used(): assert attr.asdict(slot_instance) == attr.asdict(non_slot_instance) +def test_slots_base_of_slotted(): + """ + The (hopefully gc'ed) temporary base class of a slotted class contains a + reference to the slotted class. + """ + + class Base: + pass + + Slotted = attr.s(slots=True)(Base) + + assert Slotted is Base.__attrs_base_of_slotted__() + + def test_basic_attr_funcs(): """ Comparison, `__eq__`, `__hash__`, `__repr__`, `attrs.asdict` work. @@ -755,7 +769,7 @@ def f(self): assert "__dict__" not in dir(A) -def test_slots_cached_property_works_on_frozen_isntances(): +def test_slots_cached_property_works_on_frozen_instances(): """ Infers type of cached property. """