diff --git a/CHANGES.md b/CHANGES.md index ac6912d..d0cead8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ ### 0.23.0 (2019-xx-xx xx:xx:xx UTC) +* Update attr 20.1.0.dev0 (4bd6827) to 20.2.0 (4f74fba) [develop changelog] diff --git a/lib/attr/__init__.py b/lib/attr/__init__.py index 7c1e643..7a79e57 100644 --- a/lib/attr/__init__.py +++ b/lib/attr/__init__.py @@ -1,10 +1,12 @@ from __future__ import absolute_import, division, print_function +import sys + from functools import partial -from . import converters, exceptions, filters, validators +from . import converters, exceptions, filters, setters, validators from ._config import get_run_validators, set_run_validators -from ._funcs import asdict, assoc, astuple, evolve, has +from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types from ._make import ( NOTHING, Attribute, @@ -19,7 +21,7 @@ from ._make import ( from ._version_info import VersionInfo -__version__ = "20.1.0.dev0" +__version__ = "20.2.0" __version_info__ = VersionInfo._from_version_string(__version__) __title__ = "attrs" @@ -39,7 +41,6 @@ s = attributes = attrs ib = attr = attrib dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) - __all__ = [ "Attribute", "Factory", @@ -61,8 +62,15 @@ __all__ = [ "has", "ib", "make_class", + "resolve_types", "s", "set_run_validators", + "setters", "validate", "validators", ] + +if sys.version_info[:2] >= (3, 6): + from ._next_gen import define, field, frozen, mutable + + __all__.extend((define, field, frozen, mutable)) diff --git a/lib/attr/__init__.pyi b/lib/attr/__init__.pyi index 38f16f0..0869914 100644 --- a/lib/attr/__init__.pyi +++ b/lib/attr/__init__.pyi @@ -18,6 +18,7 @@ from typing import ( from . import exceptions as exceptions from . import filters as filters from . import converters as converters +from . import setters as setters from . import validators as validators from ._version_info import VersionInfo @@ -37,20 +38,25 @@ _T = TypeVar("_T") _C = TypeVar("_C", bound=type) _ValidatorType = Callable[[Any, Attribute[_T], _T], Any] -_ConverterType = Callable[[Any], _T] +_ConverterType = Callable[[Any], Any] _FilterType = Callable[[Attribute[_T], _T], bool] _ReprType = Callable[[Any], str] _ReprArgType = Union[bool, _ReprType] -# FIXME: in reality, if multiple validators are passed they must be in a list or tuple, -# but those are invariant and so would prevent subtypes of _ValidatorType from working -# when passed in a list or tuple. +_OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any] +_OnSetAttrArgType = Union[ + _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType +] +# FIXME: in reality, if multiple validators are passed they must be in a list +# or tuple, but those are invariant and so would prevent subtypes of +# _ValidatorType from working when passed in a list or tuple. _ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] # _make -- NOTHING: object -# NOTE: Factory lies about its return type to make this possible: `x: List[int] = Factory(list)` +# NOTE: Factory lies about its return type to make this possible: +# `x: List[int] # = Factory(list)` # Work around mypy issue #4554 in the common case by using an overload. @overload def Factory(factory: Callable[[], _T]) -> _T: ... @@ -70,16 +76,17 @@ class Attribute(Generic[_T]): order: bool hash: Optional[bool] init: bool - converter: Optional[_ConverterType[_T]] + converter: Optional[_ConverterType] metadata: Dict[Any, Any] type: Optional[Type[_T]] kw_only: bool + on_setattr: _OnSetAttrType # NOTE: We had several choices for the annotation to use for type arg: # 1) Type[_T] # - Pros: Handles simple cases correctly -# - Cons: Might produce less informative errors in the case of conflicting TypeVars -# e.g. `attr.ib(default='bad', type=int)` +# - Cons: Might produce less informative errors in the case of conflicting +# TypeVars e.g. `attr.ib(default='bad', type=int)` # 2) Callable[..., _T] # - Pros: Better error messages than #1 for conflicting TypeVars # - Cons: Terrible error messages for validator checks. @@ -97,7 +104,8 @@ class Attribute(Generic[_T]): # This makes this type of assignments possible: # x: int = attr(8) # -# This form catches explicit None or no default but with no other arguments returns Any. +# This form catches explicit None or no default but with no other arguments +# returns Any. @overload def attrib( default: None = ..., @@ -113,9 +121,11 @@ def attrib( kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... -# This form catches an explicit None or no default and infers the type from the other arguments. +# This form catches an explicit None or no default and infers the type from the +# other arguments. @overload def attrib( default: None = ..., @@ -126,11 +136,12 @@ def attrib( init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: Optional[Type[_T]] = ..., - converter: Optional[_ConverterType[_T]] = ..., + converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form catches an explicit default argument. @@ -144,11 +155,12 @@ def attrib( init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: Optional[Type[_T]] = ..., - converter: Optional[_ConverterType[_T]] = ..., + converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form covers type=non-Type: e.g. forward references (str), Any @@ -162,11 +174,83 @@ def attrib( init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: object = ..., - converter: Optional[_ConverterType[_T]] = ..., + converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... +@overload +def field( + *, + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def field( + *, + default: None = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def field( + *, + default: _T, + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def field( + *, + default: Optional[_T] = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... @overload def attrs( @@ -187,6 +271,9 @@ def attrs( auto_exc: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _C: ... @overload def attrs( @@ -207,8 +294,58 @@ def attrs( auto_exc: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Callable[[_C], _C]: ... +@overload +def define( + maybe_cls: _C, + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _C: ... +@overload +def define( + maybe_cls: None = ..., + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Callable[[_C], _C]: ... +mutable = define +frozen = define # they differ only in their defaults + # TODO: add support for returning NamedTuple from the mypy plugin class _Fields(Tuple[Attribute[Any], ...]): def __getattr__(self, name: str) -> Attribute[Any]: ... @@ -216,9 +353,15 @@ class _Fields(Tuple[Attribute[Any], ...]): def fields(cls: type) -> _Fields: ... def fields_dict(cls: type) -> Dict[str, Attribute[Any]]: ... def validate(inst: Any) -> None: ... +def resolve_types( + cls: _C, + globalns: Optional[Dict[str, Any]] = ..., + localns: Optional[Dict[str, Any]] = ..., +) -> _C: ... # TODO: add support for returning a proper attrs class from the mypy plugin -# we use Any instead of _CountingAttr so that e.g. `make_class('Foo', [attr.ib()])` is valid +# we use Any instead of _CountingAttr so that e.g. `make_class('Foo', +# [attr.ib()])` is valid def make_class( name: str, attrs: Union[List[str], Tuple[str, ...], Dict[str, Any]], @@ -238,12 +381,14 @@ def make_class( auto_exc: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> type: ... # _funcs -- # TODO: add support for returning TypedDict from the mypy plugin -# FIXME: asdict/astuple do not honor their factory args. waiting on one of these: +# FIXME: asdict/astuple do not honor their factory args. Waiting on one of +# these: # https://github.com/python/mypy/issues/4236 # https://github.com/python/typing/issues/253 def asdict( diff --git a/lib/attr/_compat.py b/lib/attr/_compat.py index a915db8..bed5b13 100644 --- a/lib/attr/_compat.py +++ b/lib/attr/_compat.py @@ -19,9 +19,10 @@ else: if PY2: - from UserDict import IterableUserDict from collections import Mapping, Sequence + from UserDict import IterableUserDict + # We 'bundle' isclass instead of using inspect as importing inspect is # fairly expensive (order of 10-15 ms for a modern machine in 2016) def isclass(klass): diff --git a/lib/attr/_funcs.py b/lib/attr/_funcs.py index c077e42..ca92f9f 100644 --- a/lib/attr/_funcs.py +++ b/lib/attr/_funcs.py @@ -287,4 +287,52 @@ def evolve(inst, **changes): init_name = attr_name if attr_name[0] != "_" else attr_name[1:] if init_name not in changes: changes[init_name] = getattr(inst, attr_name) + return cls(**changes) + + +def resolve_types(cls, globalns=None, localns=None): + """ + Resolve any strings and forward annotations in type annotations. + + This is only required if you need concrete types in `Attribute`'s *type* + field. In other words, you don't need to resolve your types if you only + use them for static type checking. + + With no arguments, names will be looked up in the module in which the class + was created. If this is not what you want, e.g. if the name only exists + inside a method, you may pass *globalns* or *localns* to specify other + dictionaries in which to look up these names. See the docs of + `typing.get_type_hints` for more details. + + :param type cls: Class to resolve. + :param Optional[dict] globalns: Dictionary containing global variables. + :param Optional[dict] localns: Dictionary containing local variables. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + :raise NameError: If types cannot be resolved because of missing variables. + + :returns: *cls* so you can use this function also as a class decorator. + Please note that you have to apply it **after** `attr.s`. That means + the decorator has to come in the line **before** `attr.s`. + + .. versionadded:: 20.1.0 + """ + try: + # Since calling get_type_hints is expensive we cache whether we've + # done it already. + cls.__attrs_types_resolved__ + except AttributeError: + import typing + + hints = typing.get_type_hints(cls, globalns=globalns, localns=localns) + for field in fields(cls): + if field.name in hints: + # Since fields have been frozen we must work around it. + _obj_setattr(field, "type", hints[field.name]) + cls.__attrs_types_resolved__ = True + + # Return the class so you can use it as a decorator too. + return cls diff --git a/lib/attr/_make.py b/lib/attr/_make.py index f32ae95..0fbbd7c 100644 --- a/lib/attr/_make.py +++ b/lib/attr/_make.py @@ -9,7 +9,7 @@ import warnings from operator import itemgetter -from . import _config +from . import _config, setters from ._compat import ( PY2, isclass, @@ -29,7 +29,7 @@ from .exceptions import ( # This is used at least twice, so cache it here. _obj_setattr = object.__setattr__ -_init_converter_pat = "__attr_converter_{}" +_init_converter_pat = "__attr_converter_%s" _init_factory_pat = "__attr_factory_{}" _tuple_property_pat = ( " {attr_name} = _attrs_property(_attrs_itemgetter({index}))" @@ -109,6 +109,7 @@ def attrib( kw_only=False, eq=None, order=None, + on_setattr=None, ): """ Create a new attribute on a class. @@ -126,7 +127,7 @@ def attrib( used to construct a new value (useful for mutable data types like lists or dicts). - If a default is not set (or set manually to ``attr.NOTHING``), a value + If a default is not set (or set manually to `attr.NOTHING`), a value *must* be supplied when instantiating; otherwise a `TypeError` will be raised. @@ -134,8 +135,8 @@ def attrib( :type default: Any value - :param factory: Syntactic sugar for ``default=attr.Factory(callable)``. - :type factory: `callable` + :param callable factory: Syntactic sugar for + ``default=attr.Factory(factory)``. :param validator: `callable` that is called by ``attrs``-generated ``__init__`` methods after the instance has been initialized. They @@ -170,21 +171,19 @@ def attrib( generated ``__lt__``, ``__le__``, ``__gt__`` and ``__ge__`` methods. :param bool cmp: Setting to ``True`` is equivalent to setting ``eq=True, order=True``. Deprecated in favor of *eq* and *order*. - :param hash: Include this attribute in the generated ``__hash__`` - method. If ``None`` (default), mirror *eq*'s value. This is the - correct behavior according the Python spec. Setting this value to - anything else than ``None`` is *discouraged*. - :type hash: `bool` or `None` + :param Optional[bool] hash: Include this attribute in the generated + ``__hash__`` method. If ``None`` (default), mirror *eq*'s value. This + is the correct behavior according the Python spec. Setting this value + to anything else than ``None`` is *discouraged*. :param bool init: Include this attribute in the generated ``__init__`` method. It is possible to set this to ``False`` and set a default value. In that case this attributed is unconditionally initialized with the specified default value or factory. - :param converter: `callable` that is called by + :param callable converter: `callable` that is called by ``attrs``-generated ``__init__`` methods to convert attribute's value to the desired format. It is given the passed-in value, and the returned value will be used as the new value of the attribute. The value is converted before being passed to the validator, if any. - :type converter: `callable` :param metadata: An arbitrary mapping, to be used by third-party components. See `extending_metadata`. :param type: The type of the attribute. In Python 3.6 or greater, the @@ -200,6 +199,12 @@ def attrib( :param kw_only: Make this attribute keyword-only (Python 3+) in the generated ``__init__`` (if ``init`` is ``False``, this parameter is ignored). + :param on_setattr: Allows to overwrite the *on_setattr* setting from + `attr.s`. If left `None`, the *on_setattr* value from `attr.s` is used. + Set to `attr.setters.NO_OP` to run **no** `setattr` hooks for this + attribute -- regardless of the setting in `attr.s`. + :type on_setattr: `callable`, or a list of callables, or `None`, or + `attr.setters.NO_OP` .. versionadded:: 15.2.0 *convert* .. versionadded:: 16.3.0 *metadata* @@ -217,8 +222,9 @@ def attrib( .. versionchanged:: 19.2.0 *repr* also accepts a custom callable. .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *on_setattr* """ - eq, order = _determine_eq_order(cmp, eq, order) + eq, order = _determine_eq_order(cmp, eq, order, True) if hash is not None and hash is not True and hash is not False: raise TypeError( @@ -238,6 +244,16 @@ def attrib( if metadata is None: metadata = {} + # Apply syntactic sugar by auto-wrapping. + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + if validator and isinstance(validator, (list, tuple)): + validator = and_(*validator) + + if converter and isinstance(converter, (list, tuple)): + converter = pipe(*converter) + return _CountingAttr( default=default, validator=validator, @@ -251,6 +267,7 @@ def attrib( kw_only=kw_only, eq=eq, order=order, + on_setattr=on_setattr, ) @@ -308,20 +325,32 @@ def _is_class_var(annot): return str(annot).startswith(_classvar_prefixes) -def _get_annotations(cls): +def _has_own_attribute(cls, attrib_name): """ - Get annotations for *cls*. + Check whether *cls* defines *attrib_name* (and doesn't just inherit it). + + Requires Python 3. """ - anns = getattr(cls, "__annotations__", None) - if anns is None: - return {} + attr = getattr(cls, attrib_name, _sentinel) + if attr is _sentinel: + return False - # Verify that the annotations aren't merely inherited. for base_cls in cls.__mro__[1:]: - if anns is getattr(base_cls, "__annotations__", None): - return {} + a = getattr(base_cls, attrib_name, None) + if attr is a: + return False - return anns + return True + + +def _get_annotations(cls): + """ + Get annotations for *cls*. + """ + if _has_own_attribute(cls, "__annotations__"): + return cls.__annotations__ + + return {} def _counter_getter(e): @@ -331,12 +360,74 @@ def _counter_getter(e): return e[1].counter -def _transform_attrs(cls, these, auto_attribs, kw_only): +def _collect_base_attrs(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in reversed(cls.__mro__[1:-1]): + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.inherited or a.name in taken_attr_names: + continue + + a = a._assoc(inherited=True) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + # For each name, only keep the freshest definition i.e. the furthest at the + # back. base_attr_map is fine because it gets overwritten with every new + # instance. + filtered = [] + seen = set() + for a in reversed(base_attrs): + if a.name in seen: + continue + filtered.insert(0, a) + seen.add(a.name) + + return filtered, base_attr_map + + +def _collect_base_attrs_broken(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + + N.B. *taken_attr_names* will be mutated. + + Adhere to the old incorrect behavior. + + Notably it collects from the front and considers inherited attributes which + leads to the buggy behavior reported in #428. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in cls.__mro__[1:-1]: + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.name in taken_attr_names: + continue + + a = a._assoc(inherited=True) + taken_attr_names.add(a.name) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + return base_attrs, base_attr_map + + +def _transform_attrs(cls, these, auto_attribs, kw_only, collect_by_mro): """ Transform all `_CountingAttr`s on a class into `Attribute`s. If *these* is passed, use that and don't look for them on the class. + *collect_by_mro* is True, collect them in the correct MRO order, otherwise + use the old -- incorrect -- order. See #428. + Return an `_Attributes`. """ cd = cls.__dict__ @@ -393,22 +484,14 @@ def _transform_attrs(cls, these, auto_attribs, kw_only): for attr_name, ca in ca_list ] - base_attrs = [] - base_attr_map = {} # A dictionary of base attrs to their classes. - taken_attr_names = {a.name: a for a in own_attrs} - - # Traverse the MRO and collect attributes. - for base_cls in cls.__mro__[1:-1]: - sub_attrs = getattr(base_cls, "__attrs_attrs__", None) - if sub_attrs is not None: - for a in sub_attrs: - prev_a = taken_attr_names.get(a.name) - # Only add an attribute if it hasn't been defined before. This - # allows for overwriting attribute definitions by subclassing. - if prev_a is None: - base_attrs.append(a) - taken_attr_names[a.name] = a - base_attr_map[a.name] = base_cls + if collect_by_mro: + base_attrs, base_attr_map = _collect_base_attrs( + cls, {a.name for a in own_attrs} + ) + else: + base_attrs, base_attr_map = _collect_base_attrs_broken( + cls, {a.name for a in own_attrs} + ) attr_names = [a.name for a in base_attrs + own_attrs] @@ -458,19 +541,22 @@ class _ClassBuilder(object): """ __slots__ = ( - "_cls", - "_cls_dict", + "_attr_names", "_attrs", + "_base_attr_map", "_base_names", - "_attr_names", - "_slots", - "_frozen", - "_weakref_slot", "_cache_hash", - "_has_post_init", + "_cls", + "_cls_dict", "_delete_attribs", - "_base_attr_map", + "_frozen", + "_has_post_init", "_is_exc", + "_on_setattr", + "_slots", + "_weakref_slot", + "_has_own_setattr", + "_has_custom_setattr", ) def __init__( @@ -480,13 +566,17 @@ class _ClassBuilder(object): slots, frozen, weakref_slot, + getstate_setstate, auto_attribs, kw_only, cache_hash, is_exc, + collect_by_mro, + on_setattr, + has_custom_setattr, ): attrs, base_attrs, base_map = _transform_attrs( - cls, these, auto_attribs, kw_only + cls, these, auto_attribs, kw_only, collect_by_mro ) self._cls = cls @@ -496,12 +586,16 @@ class _ClassBuilder(object): self._base_attr_map = base_map self._attr_names = tuple(a.name for a in attrs) self._slots = slots - self._frozen = frozen or _has_frozen_base_class(cls) + self._frozen = frozen self._weakref_slot = weakref_slot self._cache_hash = cache_hash 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._has_custom_setattr = has_custom_setattr + self._has_own_setattr = False self._cls_dict["__attrs_attrs__"] = self._attrs @@ -509,6 +603,14 @@ class _ClassBuilder(object): self._cls_dict["__setattr__"] = _frozen_setattrs self._cls_dict["__delattr__"] = _frozen_delattrs + self._has_own_setattr = True + + if getstate_setstate: + ( + self._cls_dict["__getstate__"], + self._cls_dict["__setstate__"], + ) = self._make_getstate_setstate() + def __repr__(self): return "<_ClassBuilder(cls={cls})>".format(cls=self._cls.__name__) @@ -549,6 +651,16 @@ class _ClassBuilder(object): for name, value in self._cls_dict.items(): setattr(cls, name, value) + # If we've inherited an attrs __setattr__ and don't write our own, + # reset it to object's. + if not self._has_own_setattr and getattr( + cls, "__attrs_own_setattr__", False + ): + cls.__attrs_own_setattr__ = False + + if not self._has_custom_setattr: + cls.__setattr__ = object.__setattr__ + return cls def _create_slots_class(self): @@ -562,11 +674,27 @@ class _ClassBuilder(object): if k not in tuple(self._attr_names) + ("__dict__", "__weakref__") } - weakref_inherited = False + # If our class doesn't have its own implementation of __setattr__ + # (either from the user or by us), check the bases, if one of them has + # an attrs-made __setattr__, that needs to be reset. We don't walk the + # MRO because we only care about our immediate base classes. + # XXX: This can be confused by subclassing a slotted attrs class with + # XXX: a non-attrs class and subclass the resulting class with an attrs + # XXX: class. See `test_slotted_confused` for details. For now that's + # XXX: OK with us. + if not self._has_own_setattr: + cd["__attrs_own_setattr__"] = False + + if not self._has_custom_setattr: + for base_cls in self._cls.__bases__: + if base_cls.__dict__.get("__attrs_own_setattr__", False): + cd["__setattr__"] = object.__setattr__ + break # Traverse the MRO to check for an existing __weakref__. + weakref_inherited = False for base_cls in self._cls.__mro__[1:-1]: - if "__weakref__" in getattr(base_cls, "__dict__", ()): + if base_cls.__dict__.get("__weakref__", None) is not None: weakref_inherited = True break @@ -580,7 +708,7 @@ class _ClassBuilder(object): names += ("__weakref__",) # We only add the names of attributes that aren't inherited. - # Settings __slots__ to inherited attributes wastes memory. + # Setting __slots__ to inherited attributes wastes memory. slot_names = [name for name in names if name not in base_names] if self._cache_hash: slot_names.append(_hash_cache_field) @@ -590,37 +718,6 @@ class _ClassBuilder(object): if qualname is not None: cd["__qualname__"] = qualname - # __weakref__ is not writable. - state_attr_names = tuple( - an for an in self._attr_names if an != "__weakref__" - ) - - def slots_getstate(self): - """ - Automatically created by attrs. - """ - return tuple(getattr(self, name) for name in state_attr_names) - - hash_caching_enabled = self._cache_hash - - def slots_setstate(self, state): - """ - Automatically created by attrs. - """ - __bound_setattr = _obj_setattr.__get__(self, Attribute) - for name, value in zip(state_attr_names, state): - __bound_setattr(name, value) - - # The hash code cache is not included when the object is - # serialized, but it still needs to be initialized to None to - # indicate that the first call to __hash__ should be a cache miss. - if hash_caching_enabled: - __bound_setattr(_hash_cache_field, None) - - # slots and frozen require __getstate__/__setstate__ to work - cd["__getstate__"] = slots_getstate - cd["__setstate__"] = slots_setstate - # Create new class based on old class and our methods. cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) @@ -641,8 +738,13 @@ class _ClassBuilder(object): if not closure_cells: # Catch None or the empty list. continue for cell in closure_cells: - if cell.cell_contents is self._cls: - set_closure_cell(cell, cls) + try: + match = cell.cell_contents is self._cls + except ValueError: # ValueError: Cell is empty + pass + else: + if match: + set_closure_cell(cell, cls) return cls @@ -665,6 +767,40 @@ class _ClassBuilder(object): self._cls_dict["__str__"] = self._add_method_dunders(__str__) return self + def _make_getstate_setstate(self): + """ + Create custom __setstate__ and __getstate__ methods. + """ + # __weakref__ is not writable. + state_attr_names = tuple( + an for an in self._attr_names if an != "__weakref__" + ) + + def slots_getstate(self): + """ + Automatically created by attrs. + """ + return tuple(getattr(self, name) for name in state_attr_names) + + hash_caching_enabled = self._cache_hash + + def slots_setstate(self, state): + """ + Automatically created by attrs. + """ + __bound_setattr = _obj_setattr.__get__(self, Attribute) + for name, value in zip(state_attr_names, state): + __bound_setattr(name, value) + + # The hash code cache is not included when the object is + # serialized, but it still needs to be initialized to None to + # indicate that the first call to __hash__ should be a cache + # miss. + if hash_caching_enabled: + __bound_setattr(_hash_cache_field, None) + + return slots_getstate, slots_setstate + def make_unhashable(self): self._cls_dict["__hash__"] = None return self @@ -692,6 +828,8 @@ class _ClassBuilder(object): self._cache_hash, self._base_attr_map, self._is_exc, + self._on_setattr is not None + and self._on_setattr is not setters.NO_OP, ) ) @@ -717,6 +855,42 @@ class _ClassBuilder(object): return self + def add_setattr(self): + if self._frozen: + return self + + sa_attrs = {} + for a in self._attrs: + on_setattr = a.on_setattr or self._on_setattr + if on_setattr and on_setattr is not setters.NO_OP: + sa_attrs[a.name] = a, on_setattr + + if not sa_attrs: + return self + + if self._has_custom_setattr: + # We need to write a __setattr__ but there already is one! + raise ValueError( + "Can't combine custom __setattr__ with on_setattr hooks." + ) + + # docstring comes from _add_method_dunders + def __setattr__(self, name, val): + try: + a, hook = sa_attrs[name] + except KeyError: + nval = val + else: + nval = hook(self, a, val) + + _obj_setattr(self, name, nval) + + self._cls_dict["__attrs_own_setattr__"] = True + self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) + self._has_own_setattr = True + + return self + def _add_method_dunders(self, method): """ Add __module__ and __qualname__ to a *method* if possible. @@ -734,8 +908,8 @@ class _ClassBuilder(object): pass try: - method.__doc__ = "Method generated by attrs for class {}.".format( - self._cls.__qualname__ + method.__doc__ = "Method generated by attrs for class %s." % ( + self._cls.__qualname__, ) except AttributeError: pass @@ -749,10 +923,10 @@ _CMP_DEPRECATION = ( ) -def _determine_eq_order(cmp, eq, order): +def _determine_eq_order(cmp, eq, order, default_eq): """ Validate the combination of *cmp*, *eq*, and *order*. Derive the effective - values of eq and order. + values of eq and order. If *eq* is None, set it to *default_eq*. """ if cmp is not None and any((eq is not None, order is not None)): raise ValueError("Don't mix `cmp` with `eq' and `order`.") @@ -763,9 +937,10 @@ def _determine_eq_order(cmp, eq, order): return cmp, cmp - # If left None, equality is on and ordering mirrors equality. + # If left None, equality is set to the specified default and ordering + # mirrors equality. if eq is None: - eq = True + eq = default_eq if order is None: order = eq @@ -776,14 +951,42 @@ def _determine_eq_order(cmp, eq, order): return eq, order +def _determine_whether_to_implement( + cls, flag, auto_detect, dunders, default=True +): + """ + Check whether we should implement a set of methods for *cls*. + + *flag* is the argument passed into @attr.s like 'init', *auto_detect* the + same as passed into @attr.s and *dunders* is a tuple of attribute names + whose presence signal that the user has implemented it themselves. + + Return *default* if no reason for either for or against is found. + + auto_detect must be False on Python 2. + """ + if flag is True or flag is False: + return flag + + if flag is None and auto_detect is False: + return default + + # Logically, flag is None and auto_detect is True here. + for dunder in dunders: + if _has_own_attribute(cls, dunder): + return False + + return default + + def attrs( maybe_cls=None, these=None, repr_ns=None, - repr=True, + repr=None, cmp=None, hash=None, - init=True, + init=None, slots=False, frozen=False, weakref_slot=True, @@ -794,6 +997,10 @@ def attrs( auto_exc=False, eq=None, order=None, + auto_detect=False, + collect_by_mro=False, + getstate_setstate=None, + on_setattr=None, ): r""" A class decorator that adds `dunder @@ -818,28 +1025,52 @@ def attrs( :param str repr_ns: When using nested classes, there's no way in Python 2 to automatically detect that. Therefore it's possible to set the namespace explicitly for a more meaningful ``repr`` output. + :param bool auto_detect: Instead of setting the *init*, *repr*, *eq*, + *order*, and *hash* arguments explicitly, assume they are set to + ``True`` **unless any** of the involved methods for one of the + arguments is implemented in the *current* class (i.e. it is *not* + inherited from some base class). + + So for example by implementing ``__eq__`` on a class yourself, + ``attrs`` will deduce ``eq=False`` and won't create *neither* + ``__eq__`` *nor* ``__ne__`` (but Python classes come with a sensible + ``__ne__`` by default, so it *should* be enough to only implement + ``__eq__`` in most cases). + + .. warning:: + + If you prevent ``attrs`` from creating the ordering methods for you + (``order=False``, e.g. by implementing ``__le__``), it becomes + *your* responsibility to make sure its ordering is sound. The best + way is to use the `functools.total_ordering` decorator. + + + Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*, + *cmp*, or *hash* overrides whatever *auto_detect* would determine. + + *auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises + a `PythonTooOldError`. + :param bool repr: Create a ``__repr__`` method with a human readable representation of ``attrs`` attributes.. :param bool str: Create a ``__str__`` method that is identical to ``__repr__``. This is usually not necessary except for `Exception`\ s. - :param bool eq: If ``True`` or ``None`` (default), add ``__eq__`` and - ``__ne__`` methods that check two instances for equality. + :param Optional[bool] eq: If ``True`` or ``None`` (default), add ``__eq__`` + and ``__ne__`` methods that check two instances for equality. They compare the instances as if they were tuples of their ``attrs`` attributes if and only if the types of both classes are *identical*! - :type eq: `bool` or `None` - :param bool order: If ``True``, add ``__lt__``, ``__le__``, ``__gt__``, - and ``__ge__`` methods that behave like *eq* above and allow instances - to be ordered. If ``None`` (default) mirror value of *eq*. - :type order: `bool` or `None` - :param cmp: Setting to ``True`` is equivalent to setting ``eq=True, - order=True``. Deprecated in favor of *eq* and *order*, has precedence - over them for backward-compatibility though. Must not be mixed with - *eq* or *order*. - :type cmp: `bool` or `None` - :param hash: If ``None`` (default), the ``__hash__`` method is generated - according how *eq* and *frozen* are set. + :param Optional[bool] order: If ``True``, add ``__lt__``, ``__le__``, + ``__gt__``, and ``__ge__`` methods that behave like *eq* above and + allow instances to be ordered. If ``None`` (default) mirror value of + *eq*. + :param Optional[bool] cmp: Setting to ``True`` is equivalent to setting + ``eq=True, order=True``. Deprecated in favor of *eq* and *order*, has + precedence over them for backward-compatibility though. Must not be + mixed with *eq* or *order*. + :param Optional[bool] hash: If ``None`` (default), the ``__hash__`` method + is generated according how *eq* and *frozen* are set. 1. If *both* are True, ``attrs`` will generate a ``__hash__`` for you. 2. If *eq* is True and *frozen* is False, ``__hash__`` will be set to @@ -857,7 +1088,6 @@ def attrs( `object.__hash__`, and the `GitHub issue that led to the default \ behavior `_ for more details. - :type hash: `bool` or `None` :param bool init: Create a ``__init__`` method that initializes the ``attrs`` attributes. Leading underscores are stripped for the argument name. If a ``__attrs_post_init__`` method exists on the @@ -884,10 +1114,12 @@ def attrs( circumvent that limitation by using ``object.__setattr__(self, "attribute_name", value)``. + 5. Subclasses of a frozen class are frozen too. + :param bool weakref_slot: Make instances weak-referenceable. This has no effect unless ``slots`` is also enabled. - :param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes - (Python 3.6 and later only) from the class body. + :param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated + attributes (Python 3.6 and later only) from the class body. In this case, you **must** annotate every field. If ``attrs`` encounters a field that is set to an `attr.ib` but lacks a type @@ -927,6 +1159,41 @@ def attrs( default value are additionally available as a tuple in the ``args`` attribute, - the value of *str* is ignored leaving ``__str__`` to base classes. + :param bool collect_by_mro: Setting this to `True` fixes the way ``attrs`` + collects attributes from base classes. The default behavior is + incorrect in certain cases of multiple inheritance. It should be on by + default but is kept off for backward-compatability. + + See issue `#428 `_ for + more details. + + :param Optional[bool] getstate_setstate: + .. note:: + This is usually only interesting for slotted classes and you should + probably just set *auto_detect* to `True`. + + If `True`, ``__getstate__`` and + ``__setstate__`` are generated and attached to the class. This is + necessary for slotted classes to be pickleable. If left `None`, it's + `True` by default for slotted classes and ``False`` for dict classes. + + If *auto_detect* is `True`, and *getstate_setstate* is left `None`, + and **either** ``__getstate__`` or ``__setstate__`` is detected directly + on the class (i.e. not inherited), it is set to `False` (this is usually + what you want). + + :param on_setattr: A callable that is run whenever the user attempts to set + an attribute (either by assignment like ``i.x = 42`` or by using + `setattr` like ``setattr(i, "x", 42)``). It receives the same argument + as validators: the instance, the attribute that is being modified, and + the new value. + + If no exception is raised, the attribute is set to the return value of + the callable. + + If a list of callables is passed, they're automatically wrapped in an + `attr.setters.pipe`. + .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* @@ -952,37 +1219,84 @@ def attrs( .. versionadded:: 19.1.0 *auto_exc* .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. .. versionadded:: 19.2.0 *eq* and *order* - """ - eq, order = _determine_eq_order(cmp, eq, order) + .. versionadded:: 20.1.0 *auto_detect* + .. versionadded:: 20.1.0 *collect_by_mro* + .. versionadded:: 20.1.0 *getstate_setstate* + .. versionadded:: 20.1.0 *on_setattr* + """ + if auto_detect and PY2: + raise PythonTooOldError( + "auto_detect only works on Python 3 and later." + ) + + eq_, order_ = _determine_eq_order(cmp, eq, order, None) + hash_ = hash # work around the lack of nonlocal + + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) def wrap(cls): if getattr(cls, "__class__", None) is None: raise TypeError("attrs only works with new-style classes.") + 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( + cls, "__setattr__" + ) + + if has_own_setattr and is_frozen: + raise ValueError("Can't freeze a class with a custom __setattr__.") builder = _ClassBuilder( cls, these, slots, - frozen, + is_frozen, weakref_slot, + _determine_whether_to_implement( + cls, + getstate_setstate, + auto_detect, + ("__getstate__", "__setstate__"), + default=slots, + ), auto_attribs, kw_only, cache_hash, is_exc, + collect_by_mro, + on_setattr, + has_own_setattr, ) - - if repr is True: + if _determine_whether_to_implement( + cls, repr, auto_detect, ("__repr__",) + ): builder.add_repr(repr_ns) if str is True: builder.add_str() - if eq is True and not is_exc: + + eq = _determine_whether_to_implement( + cls, eq_, auto_detect, ("__eq__", "__ne__") + ) + if not is_exc and eq is True: builder.add_eq() - if order is True and not is_exc: + if not is_exc and _determine_whether_to_implement( + cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__") + ): builder.add_order() + builder.add_setattr() + + if ( + hash_ is None + and auto_detect is True + and _has_own_attribute(cls, "__hash__") + ): + hash = False + else: + hash = hash_ 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. raise TypeError( @@ -997,7 +1311,9 @@ def attrs( " hashing must be either explicitly or implicitly " "enabled." ) - elif hash is True or (hash is None and eq is True and frozen is True): + 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. builder.add_hash() else: @@ -1010,7 +1326,9 @@ def attrs( ) builder.make_unhashable() - if init is True: + if _determine_whether_to_implement( + cls, init, auto_detect, ("__init__",) + ): builder.add_init() else: if cache_hash: @@ -1383,43 +1701,6 @@ def _add_repr(cls, ns=None, attrs=None): return cls -def _make_init( - cls, attrs, post_init, frozen, slots, cache_hash, base_attr_map, is_exc -): - attrs = [a for a in attrs if a.init or a.default is not NOTHING] - - unique_filename = _generate_unique_filename(cls, "init") - - script, globs, annotations = _attrs_to_init_script( - attrs, frozen, slots, post_init, cache_hash, base_attr_map, is_exc - ) - locs = {} - bytecode = compile(script, unique_filename, "exec") - attr_dict = dict((a.name, a) for a in attrs) - globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) - - if frozen is True: - # Save the lookup overhead in __init__ if we need to circumvent - # immutability. - globs["_cached_setattr"] = _obj_setattr - - eval(bytecode, globs, locs) - - # In order of debuggers like PDB being able to step through the code, - # we add a fake linecache entry. - linecache.cache[unique_filename] = ( - len(script), - None, - script.splitlines(True), - unique_filename, - ) - - __init__ = locs["__init__"] - __init__.__annotations__ = annotations - - return __init__ - - def fields(cls): """ Return the tuple of ``attrs`` attributes for a class. @@ -1504,8 +1785,134 @@ def _is_slot_attr(a_name, base_attr_map): return a_name in base_attr_map and _is_slot_cls(base_attr_map[a_name]) +def _make_init( + cls, + attrs, + post_init, + frozen, + slots, + cache_hash, + base_attr_map, + is_exc, + has_global_on_setattr, +): + if frozen and has_global_on_setattr: + raise ValueError("Frozen classes can't use on_setattr.") + + needs_cached_setattr = cache_hash or frozen + filtered_attrs = [] + attr_dict = {} + for a in attrs: + if not a.init and a.default is NOTHING: + continue + + filtered_attrs.append(a) + attr_dict[a.name] = a + + if a.on_setattr is not None: + if frozen is True: + raise ValueError("Frozen classes can't use on_setattr.") + + needs_cached_setattr = True + elif ( + has_global_on_setattr and a.on_setattr is not setters.NO_OP + ) or _is_slot_attr(a.name, base_attr_map): + needs_cached_setattr = True + + unique_filename = _generate_unique_filename(cls, "init") + + script, globs, annotations = _attrs_to_init_script( + filtered_attrs, + frozen, + slots, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_global_on_setattr, + ) + locs = {} + bytecode = compile(script, unique_filename, "exec") + globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) + + if needs_cached_setattr: + # Save the lookup overhead in __init__ if we need to circumvent + # setattr hooks. + globs["_cached_setattr"] = _obj_setattr + + eval(bytecode, globs, locs) + + # In order of debuggers like PDB being able to step through the code, + # we add a fake linecache entry. + linecache.cache[unique_filename] = ( + len(script), + None, + script.splitlines(True), + unique_filename, + ) + + __init__ = locs["__init__"] + __init__.__annotations__ = annotations + + return __init__ + + +def _setattr(attr_name, value_var, has_on_setattr): + """ + Use the cached object.setattr to set *attr_name* to *value_var*. + """ + return "_setattr('%s', %s)" % (attr_name, value_var) + + +def _setattr_with_converter(attr_name, value_var, has_on_setattr): + """ + Use the cached object.setattr to set *attr_name* to *value_var*, but run + its converter first. + """ + return "_setattr('%s', %s(%s))" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + +def _assign(attr_name, value, has_on_setattr): + """ + Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise + relegate to _setattr. + """ + if has_on_setattr: + return _setattr(attr_name, value, True) + + return "self.%s = %s" % (attr_name, value) + + +def _assign_with_converter(attr_name, value_var, has_on_setattr): + """ + Unless *attr_name* has an on_setattr hook, use normal assignment after + conversion. Otherwise relegate to _setattr_with_converter. + """ + if has_on_setattr: + return _setattr_with_converter(attr_name, value_var, True) + + return "self.%s = %s(%s)" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + def _attrs_to_init_script( - attrs, frozen, slots, post_init, cache_hash, base_attr_map, is_exc + attrs, + frozen, + slots, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_global_on_setattr, ): """ Return a script of an initializer for *attrs* and a dict of globals. @@ -1516,85 +1923,49 @@ def _attrs_to_init_script( a cached ``object.__setattr__``. """ lines = [] - any_slot_ancestors = any( - _is_slot_attr(a.name, base_attr_map) for a in attrs - ) + if needs_cached_setattr: + lines.append( + # Circumvent the __setattr__ descriptor to save one lookup per + # assignment. + # Note _setattr will be used again below if cache_hash is True + "_setattr = _cached_setattr.__get__(self, self.__class__)" + ) + if frozen is True: if slots is True: - lines.append( - # Circumvent the __setattr__ descriptor to save one lookup per - # assignment. - # Note _setattr will be used again below if cache_hash is True - "_setattr = _cached_setattr.__get__(self, self.__class__)" - ) - - def fmt_setter(attr_name, value_var): - return "_setattr('%(attr_name)s', %(value_var)s)" % { - "attr_name": attr_name, - "value_var": value_var, - } - - def fmt_setter_with_converter(attr_name, value_var): - conv_name = _init_converter_pat.format(attr_name) - return "_setattr('%(attr_name)s', %(conv)s(%(value_var)s))" % { - "attr_name": attr_name, - "value_var": value_var, - "conv": conv_name, - } - + fmt_setter = _setattr + fmt_setter_with_converter = _setattr_with_converter else: # Dict frozen classes assign directly to __dict__. # But only if the attribute doesn't come from an ancestor slot # class. # Note _inst_dict will be used again below if cache_hash is True lines.append("_inst_dict = self.__dict__") - if any_slot_ancestors: - lines.append( - # Circumvent the __setattr__ descriptor to save one lookup - # per assignment. - "_setattr = _cached_setattr.__get__(self, self.__class__)" - ) - def fmt_setter(attr_name, value_var): + def fmt_setter(attr_name, value_var, has_on_setattr): if _is_slot_attr(attr_name, base_attr_map): - res = "_setattr('%(attr_name)s', %(value_var)s)" % { - "attr_name": attr_name, - "value_var": value_var, - } - else: - res = "_inst_dict['%(attr_name)s'] = %(value_var)s" % { - "attr_name": attr_name, - "value_var": value_var, - } - return res - - def fmt_setter_with_converter(attr_name, value_var): - conv_name = _init_converter_pat.format(attr_name) - if _is_slot_attr(attr_name, base_attr_map): - tmpl = "_setattr('%(attr_name)s', %(c)s(%(value_var)s))" - else: - tmpl = "_inst_dict['%(attr_name)s'] = %(c)s(%(value_var)s)" - return tmpl % { - "attr_name": attr_name, - "value_var": value_var, - "c": conv_name, - } + return _setattr(attr_name, value_var, has_on_setattr) + + return "_inst_dict['%s'] = %s" % (attr_name, value_var) + + def fmt_setter_with_converter( + attr_name, value_var, has_on_setattr + ): + if has_on_setattr or _is_slot_attr(attr_name, base_attr_map): + return _setattr_with_converter( + attr_name, value_var, has_on_setattr + ) + + return "_inst_dict['%s'] = %s(%s)" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) else: # Not frozen. - def fmt_setter(attr_name, value): - return "self.%(attr_name)s = %(value)s" % { - "attr_name": attr_name, - "value": value, - } - - def fmt_setter_with_converter(attr_name, value_var): - conv_name = _init_converter_pat.format(attr_name) - return "self.%(attr_name)s = %(conv)s(%(value_var)s)" % { - "attr_name": attr_name, - "value_var": value_var, - "conv": conv_name, - } + fmt_setter = _assign + fmt_setter_with_converter = _assign_with_converter args = [] kw_only_args = [] @@ -1608,13 +1979,19 @@ def _attrs_to_init_script( for a in attrs: if a.validator: attrs_to_validate.append(a) + attr_name = a.name + has_on_setattr = a.on_setattr is not None or ( + a.on_setattr is not setters.NO_OP and has_global_on_setattr + ) arg_name = a.name.lstrip("_") + has_factory = isinstance(a.default, Factory) if has_factory and a.default.takes_self: maybe_self = "self" else: maybe_self = "" + if a.init is False: if has_factory: init_factory_name = _init_factory_pat.format(a.name) @@ -1622,16 +1999,18 @@ def _attrs_to_init_script( lines.append( fmt_setter_with_converter( attr_name, - init_factory_name + "({0})".format(maybe_self), + init_factory_name + "(%s)" % (maybe_self,), + has_on_setattr, ) ) - conv_name = _init_converter_pat.format(a.name) + conv_name = _init_converter_pat % (a.name,) names_for_globals[conv_name] = a.converter else: lines.append( fmt_setter( attr_name, - init_factory_name + "({0})".format(maybe_self), + init_factory_name + "(%s)" % (maybe_self,), + has_on_setattr, ) ) names_for_globals[init_factory_name] = a.default.factory @@ -1640,70 +2019,78 @@ def _attrs_to_init_script( lines.append( fmt_setter_with_converter( attr_name, - "attr_dict['{attr_name}'].default".format( - attr_name=attr_name - ), + "attr_dict['%s'].default" % (attr_name,), + has_on_setattr, ) ) - conv_name = _init_converter_pat.format(a.name) + conv_name = _init_converter_pat % (a.name,) names_for_globals[conv_name] = a.converter else: lines.append( fmt_setter( attr_name, - "attr_dict['{attr_name}'].default".format( - attr_name=attr_name - ), + "attr_dict['%s'].default" % (attr_name,), + has_on_setattr, ) ) elif a.default is not NOTHING and not has_factory: - arg = "{arg_name}=attr_dict['{attr_name}'].default".format( - arg_name=arg_name, attr_name=attr_name - ) + arg = "%s=attr_dict['%s'].default" % (arg_name, attr_name) if a.kw_only: kw_only_args.append(arg) else: args.append(arg) + if a.converter is not None: - lines.append(fmt_setter_with_converter(attr_name, arg_name)) + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) names_for_globals[ - _init_converter_pat.format(a.name) + _init_converter_pat % (a.name,) ] = a.converter else: - lines.append(fmt_setter(attr_name, arg_name)) + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + elif has_factory: - arg = "{arg_name}=NOTHING".format(arg_name=arg_name) + arg = "%s=NOTHING" % (arg_name,) if a.kw_only: kw_only_args.append(arg) else: args.append(arg) - lines.append( - "if {arg_name} is not NOTHING:".format(arg_name=arg_name) - ) + lines.append("if %s is not NOTHING:" % (arg_name,)) + init_factory_name = _init_factory_pat.format(a.name) if a.converter is not None: lines.append( - " " + fmt_setter_with_converter(attr_name, arg_name) + " " + + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) ) lines.append("else:") lines.append( " " + fmt_setter_with_converter( attr_name, - init_factory_name + "({0})".format(maybe_self), + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, ) ) names_for_globals[ - _init_converter_pat.format(a.name) + _init_converter_pat % (a.name,) ] = a.converter else: - lines.append(" " + fmt_setter(attr_name, arg_name)) + lines.append( + " " + fmt_setter(attr_name, arg_name, has_on_setattr) + ) lines.append("else:") lines.append( " " + fmt_setter( attr_name, - init_factory_name + "({0})".format(maybe_self), + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, ) ) names_for_globals[init_factory_name] = a.default.factory @@ -1712,13 +2099,18 @@ def _attrs_to_init_script( kw_only_args.append(arg_name) else: args.append(arg_name) + if a.converter is not None: - lines.append(fmt_setter_with_converter(attr_name, arg_name)) + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) names_for_globals[ - _init_converter_pat.format(a.name) + _init_converter_pat % (a.name,) ] = a.converter else: - lines.append(fmt_setter(attr_name, arg_name)) + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) if a.init is True and a.converter is None and a.type is not None: annotations[arg_name] = a.type @@ -1727,13 +2119,14 @@ def _attrs_to_init_script( names_for_globals["_config"] = _config lines.append("if _config._run_validators is True:") for a in attrs_to_validate: - val_name = "__attr_validator_{}".format(a.name) - attr_name = "__attr_{}".format(a.name) + val_name = "__attr_validator_" + a.name + attr_name = "__attr_" + a.name lines.append( - " {}(self, {}, self.{})".format(val_name, attr_name, a.name) + " %s(self, %s, self.%s)" % (val_name, attr_name, a.name) ) names_for_globals[val_name] = a.validator names_for_globals[attr_name] = a + if post_init: lines.append("self.__attrs_post_init__()") @@ -1789,11 +2182,18 @@ class Attribute(object): *Read-only* representation of an attribute. :attribute name: The name of the attribute. + :attribute inherited: Whether or not that attribute has been inherited from + a base class. Plus *all* arguments of `attr.ib` (except for ``factory`` which is only syntactic sugar for ``default=Factory(...)``. - For the version history of the fields, see `attr.ib`. + .. versionadded:: 20.1.0 *inherited* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.2.0 *inherited* is not taken into account for + equality checks and hashing anymore. + + For the full version history of the fields, see `attr.ib`. """ __slots__ = ( @@ -1809,6 +2209,8 @@ class Attribute(object): "type", "converter", "kw_only", + "inherited", + "on_setattr", ) def __init__( @@ -1820,14 +2222,16 @@ class Attribute(object): cmp, # XXX: unused, remove along with other cmp code. hash, init, + inherited, metadata=None, type=None, converter=None, kw_only=False, eq=None, order=None, + on_setattr=None, ): - eq, order = _determine_eq_order(cmp, eq, order) + eq, order = _determine_eq_order(cmp, eq, order, True) # Cache this descriptor here to speed things up later. bound_setattr = _obj_setattr.__get__(self, Attribute) @@ -1853,6 +2257,8 @@ class Attribute(object): ) bound_setattr("type", type) bound_setattr("kw_only", kw_only) + bound_setattr("inherited", inherited) + bound_setattr("on_setattr", on_setattr) def __setattr__(self, name, value): raise FrozenInstanceError() @@ -1875,6 +2281,7 @@ class Attribute(object): "validator", "default", "type", + "inherited", ) # exclude methods and deprecated alias } return cls( @@ -1883,6 +2290,7 @@ class Attribute(object): default=ca._default, type=type, cmp=None, + inherited=False, **inst_dict ) @@ -1947,13 +2355,17 @@ _a = [ order=False, hash=(name != "metadata"), init=True, + inherited=False, ) for name in Attribute.__slots__ ] Attribute = _add_hash( - _add_eq(_add_repr(Attribute, attrs=_a), attrs=_a), - attrs=[a for a in _a if a.hash], + _add_eq( + _add_repr(Attribute, attrs=_a), + attrs=[a for a in _a if a.name != "inherited"], + ), + attrs=[a for a in _a if a.hash and a.name != "inherited"], ) @@ -1979,6 +2391,7 @@ class _CountingAttr(object): "converter", "type", "kw_only", + "on_setattr", ) __attrs_attrs__ = tuple( Attribute( @@ -1992,6 +2405,8 @@ class _CountingAttr(object): kw_only=False, eq=True, order=False, + inherited=False, + on_setattr=None, ) for name in ( "counter", @@ -2001,6 +2416,7 @@ class _CountingAttr(object): "order", "hash", "init", + "on_setattr", ) ) + ( Attribute( @@ -2014,6 +2430,8 @@ class _CountingAttr(object): kw_only=False, eq=True, order=False, + inherited=False, + on_setattr=None, ), ) cls_counter = 0 @@ -2032,19 +2450,13 @@ class _CountingAttr(object): kw_only, eq, order, + on_setattr, ): _CountingAttr.cls_counter += 1 self.counter = _CountingAttr.cls_counter self._default = default - # If validator is a list/tuple, wrap it using helper validator. - if validator and isinstance(validator, (list, tuple)): - self._validator = and_(*validator) - else: - self._validator = validator - if converter and isinstance(converter, (list, tuple)): - self.converter = chain(*converter) - else: - self.converter = converter + self._validator = validator + self.converter = converter self.repr = repr self.eq = eq self.order = order @@ -2053,6 +2465,7 @@ class _CountingAttr(object): self.metadata = metadata self.type = type self.kw_only = kw_only + self.on_setattr = on_setattr def validator(self, meth): """ @@ -2121,8 +2534,7 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments): """ A quick way to create a new class called *name* with *attrs*. - :param name: The name for the new class. - :type name: str + :param str name: The name for the new class. :param attrs: A list of names or a dictionary of mappings of names to attributes. @@ -2173,7 +2585,10 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments): attributes_arguments["eq"], attributes_arguments["order"], ) = _determine_eq_order( - cmp, attributes_arguments.get("eq"), attributes_arguments.get("order") + cmp, + attributes_arguments.get("eq"), + attributes_arguments.get("order"), + True, ) return _attrs(these=cls_dict, **attributes_arguments)(type_) @@ -2202,8 +2617,7 @@ def and_(*validators): When called on a value, it runs all wrapped validators. - :param validators: Arbitrary number of validators. - :type validators: callables + :param callables validators: Arbitrary number of validators. .. versionadded:: 17.1.0 """ @@ -2218,21 +2632,22 @@ def and_(*validators): return _AndValidator(tuple(vals)) -def chain(*converters): +def pipe(*converters): """ A converter that composes multiple converters into one. - When called on a value, it runs all wrapped converters. + When called on a value, it runs all wrapped converters, returning the + *last* value. - :param converters: Arbitrary number of converters. - :type converters: callables + :param callables converters: Arbitrary number of converters. .. versionadded:: 20.1.0 """ - def chain_converter(val): + def pipe_converter(val): for converter in converters: val = converter(val) + return val - return chain_converter + return pipe_converter diff --git a/lib/attr/_next_gen.py b/lib/attr/_next_gen.py new file mode 100644 index 0000000..b5ff60e --- /dev/null +++ b/lib/attr/_next_gen.py @@ -0,0 +1,158 @@ +""" +This is a Python 3.6 and later-only, keyword-only, and **provisional** API that +calls `attr.s` with different default values. + +Provisional APIs that shall become "import attrs" one glorious day. +""" + +from functools import partial + +from attr.exceptions import UnannotatedAttributeError + +from . import setters +from ._make import NOTHING, _frozen_setattrs, attrib, attrs + + +def define( + maybe_cls=None, + *, + these=None, + repr=None, + hash=None, + init=None, + slots=True, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=None, + kw_only=False, + cache_hash=False, + auto_exc=True, + eq=None, + order=False, + auto_detect=True, + getstate_setstate=None, + on_setattr=None, +): + r""" + The only behavioral differences are the handling of the *auto_attribs* + option: + + :param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves + exactly like `attr.s`. If left `None`, `attr.s` will try to guess: + + 1. If all attributes are annotated and no `attr.ib` is found, it assumes + *auto_attribs=True*. + 2. Otherwise it assumes *auto_attribs=False* and tries to collect + `attr.ib`\ s. + + and that mutable classes (``frozen=False``) validate on ``__setattr__``. + + .. versionadded:: 20.1.0 + """ + + def do_it(cls, auto_attribs): + return attrs( + maybe_cls=cls, + these=these, + repr=repr, + hash=hash, + init=init, + slots=slots, + frozen=frozen, + weakref_slot=weakref_slot, + str=str, + auto_attribs=auto_attribs, + kw_only=kw_only, + cache_hash=cache_hash, + auto_exc=auto_exc, + eq=eq, + order=order, + auto_detect=auto_detect, + collect_by_mro=True, + getstate_setstate=getstate_setstate, + on_setattr=on_setattr, + ) + + def wrap(cls): + """ + Making this a wrapper ensures this code runs during class creation. + + We also ensure that frozen-ness of classes is inherited. + """ + nonlocal frozen, on_setattr + + had_on_setattr = on_setattr not in (None, setters.NO_OP) + + # By default, mutable classes validate on setattr. + if frozen is False and on_setattr is None: + on_setattr = setters.validate + + # However, if we subclass a frozen class, we inherit the immutability + # and disable on_setattr. + for base_cls in cls.__bases__: + if base_cls.__setattr__ is _frozen_setattrs: + if had_on_setattr: + raise ValueError( + "Frozen classes can't use on_setattr " + "(frozen-ness was inherited)." + ) + + on_setattr = setters.NO_OP + break + + if auto_attribs is not None: + return do_it(cls, auto_attribs) + + try: + return do_it(cls, True) + except UnannotatedAttributeError: + return do_it(cls, False) + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but ``None`` if used as `@attrs()`. + if maybe_cls is None: + return wrap + else: + return wrap(maybe_cls) + + +mutable = define +frozen = partial(define, frozen=True, on_setattr=None) + + +def field( + *, + default=NOTHING, + validator=None, + repr=True, + hash=None, + init=True, + metadata=None, + converter=None, + factory=None, + kw_only=False, + eq=None, + order=None, + on_setattr=None, +): + """ + Identical to `attr.ib`, except keyword-only and with some arguments + removed. + + .. versionadded:: 20.1.0 + """ + return attrib( + default=default, + validator=validator, + repr=repr, + hash=hash, + init=init, + metadata=metadata, + converter=converter, + factory=factory, + kw_only=kw_only, + eq=eq, + order=order, + on_setattr=on_setattr, + ) diff --git a/lib/attr/converters.py b/lib/attr/converters.py index 11599ee..715ce17 100644 --- a/lib/attr/converters.py +++ b/lib/attr/converters.py @@ -4,11 +4,11 @@ Commonly useful converters. from __future__ import absolute_import, division, print_function -from ._make import NOTHING, Factory, chain +from ._make import NOTHING, Factory, pipe __all__ = [ - "chain", + "pipe", "optional", "default_if_none", ] diff --git a/lib/attr/converters.pyi b/lib/attr/converters.pyi index b913876..7b0caa1 100644 --- a/lib/attr/converters.pyi +++ b/lib/attr/converters.pyi @@ -3,11 +3,9 @@ from . import _ConverterType _T = TypeVar("_T") -def chain(*validators: _ConverterType[_T]) -> _ConverterType[_T]: ... -def optional( - converter: _ConverterType[_T], -) -> _ConverterType[Optional[_T]]: ... +def pipe(*validators: _ConverterType) -> _ConverterType: ... +def optional(converter: _ConverterType) -> _ConverterType: ... @overload -def default_if_none(default: _T) -> _ConverterType[_T]: ... +def default_if_none(default: _T) -> _ConverterType: ... @overload -def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType[_T]: ... +def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ... diff --git a/lib/attr/exceptions.py b/lib/attr/exceptions.py index d1b7618..fcd8910 100644 --- a/lib/attr/exceptions.py +++ b/lib/attr/exceptions.py @@ -1,20 +1,37 @@ from __future__ import absolute_import, division, print_function -class FrozenInstanceError(AttributeError): +class FrozenError(AttributeError): """ - A frozen/immutable instance has been attempted to be modified. + A frozen/immutable instance or attribute haave been attempted to be + modified. It mirrors the behavior of ``namedtuples`` by using the same error message and subclassing `AttributeError`. - .. versionadded:: 16.1.0 + .. versionadded:: 20.1.0 """ msg = "can't set attribute" args = [msg] +class FrozenInstanceError(FrozenError): + """ + A frozen instance has been attempted to be modified. + + .. versionadded:: 16.1.0 + """ + + +class FrozenAttributeError(FrozenError): + """ + A frozen attribute has been attempted to be modified. + + .. versionadded:: 20.1.0 + """ + + class AttrsAttributeNotFoundError(ValueError): """ An ``attrs`` function couldn't find an attribute that the user asked for. @@ -51,7 +68,8 @@ class UnannotatedAttributeError(RuntimeError): class PythonTooOldError(RuntimeError): """ - An ``attrs`` feature requiring a more recent python version has been used. + It was attempted to use an ``attrs`` feature that requires a newer Python + version. .. versionadded:: 18.2.0 """ diff --git a/lib/attr/exceptions.pyi b/lib/attr/exceptions.pyi index 736fde2..f268011 100644 --- a/lib/attr/exceptions.pyi +++ b/lib/attr/exceptions.pyi @@ -1,8 +1,10 @@ from typing import Any -class FrozenInstanceError(AttributeError): +class FrozenError(AttributeError): msg: str = ... +class FrozenInstanceError(FrozenError): ... +class FrozenAttributeError(FrozenError): ... class AttrsAttributeNotFoundError(ValueError): ... class NotAnAttrsClassError(ValueError): ... class DefaultAlreadySetError(RuntimeError): ... diff --git a/lib/attr/setters.py b/lib/attr/setters.py new file mode 100644 index 0000000..240014b --- /dev/null +++ b/lib/attr/setters.py @@ -0,0 +1,77 @@ +""" +Commonly used hooks for on_setattr. +""" + +from __future__ import absolute_import, division, print_function + +from . import _config +from .exceptions import FrozenAttributeError + + +def pipe(*setters): + """ + Run all *setters* and return the return value of the last one. + + .. versionadded:: 20.1.0 + """ + + def wrapped_pipe(instance, attrib, new_value): + rv = new_value + + for setter in setters: + rv = setter(instance, attrib, rv) + + return rv + + return wrapped_pipe + + +def frozen(_, __, ___): + """ + Prevent an attribute to be modified. + + .. versionadded:: 20.1.0 + """ + raise FrozenAttributeError() + + +def validate(instance, attrib, new_value): + """ + Run *attrib*'s validator on *new_value* if it has one. + + .. versionadded:: 20.1.0 + """ + if _config._run_validators is False: + return new_value + + v = attrib.validator + if not v: + return new_value + + v(instance, attrib, new_value) + + return new_value + + +def convert(instance, attrib, new_value): + """ + Run *attrib*'s converter -- if it has one -- on *new_value* and return the + result. + + .. versionadded:: 20.1.0 + """ + c = attrib.converter + if c: + return c(new_value) + + return new_value + + +NO_OP = object() +""" +Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. + +Does not work in `pipe` or within lists. + +.. versionadded:: 20.1.0 +""" diff --git a/lib/attr/setters.pyi b/lib/attr/setters.pyi new file mode 100644 index 0000000..19bc33f --- /dev/null +++ b/lib/attr/setters.pyi @@ -0,0 +1,18 @@ +from . import _OnSetAttrType, Attribute +from typing import TypeVar, Any, NewType, NoReturn, cast + +_T = TypeVar("_T") + +def frozen( + instance: Any, attribute: Attribute, new_value: Any +) -> NoReturn: ... +def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... +def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... + +# convert is allowed to return Any, because they can be chained using pipe. +def convert( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> Any: ... + +_NoOpType = NewType("_NoOpType", object) +NO_OP: _NoOpType diff --git a/lib/attr/validators.py b/lib/attr/validators.py index 839d310..b9a7305 100644 --- a/lib/attr/validators.py +++ b/lib/attr/validators.py @@ -67,7 +67,7 @@ def instance_of(type): return _InstanceOfValidator(type) -@attrs(repr=False, frozen=True) +@attrs(repr=False, frozen=True, slots=True) class _MatchesReValidator(object): regex = attrib() flags = attrib() @@ -171,7 +171,8 @@ def provides(interface): performed using ``interface.providedBy(value)`` (see `zope.interface `_). - :param zope.interface.Interface interface: The interface to check for. + :param interface: The interface to check for. + :type interface: ``zope.interface.Interface`` :raises TypeError: With a human readable error message, the attribute (of type `attr.Attribute`), the expected interface, and the