From 161e9c44d534a937c75ebd07fc0d5bf6d0f35e23 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 10 Jul 2020 07:20:42 +0200 Subject: [PATCH 1/9] Add on_setattr hooks to attr.s and attr.ib Signed-off-by: Hynek Schlawack --- changelog.d/645.change.rst | 5 + docs/api.rst | 47 +++- docs/extending.rst | 2 +- docs/how-does-it-work.rst | 10 +- setup.py | 3 - src/attr/__init__.py | 3 +- src/attr/__init__.pyi | 13 ++ src/attr/_make.py | 464 ++++++++++++++++++++++++------------- src/attr/exceptions.py | 23 +- src/attr/exceptions.pyi | 4 +- src/attr/setters.py | 77 ++++++ src/attr/setters.pyi | 12 + src/attr/validators.py | 2 +- tests/test_dunders.py | 1 + tests/test_functional.py | 184 ++++++++++++++- tests/test_make.py | 29 ++- tests/typing_example.py | 14 ++ tox.ini | 2 +- 18 files changed, 716 insertions(+), 179 deletions(-) create mode 100644 changelog.d/645.change.rst create mode 100644 src/attr/setters.py create mode 100644 src/attr/setters.pyi diff --git a/changelog.d/645.change.rst b/changelog.d/645.change.rst new file mode 100644 index 000000000..90d1a536c --- /dev/null +++ b/changelog.d/645.change.rst @@ -0,0 +1,5 @@ +It is now possible to specify hooks that are called whenever an attribute is set **after** a class has been instantiated. + +You can pass ``on_setattr`` both to ``@attr.s()`` to set the default for all attributes on a class, and to ``@attr.ib()`` to overwrite it for individual attributes. + +``attrs`` also comes with a new module ``attr.setters`` that brings helpers that run validators, converters, or allow to freeze a subset of attributes. diff --git a/docs/api.rst b/docs/api.rst index ae4d1dfb9..d89c3f3d5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -16,6 +16,8 @@ What follows is the API explanation, if you'd like a more hands-on introduction, Core ---- +.. autodata:: attr.NOTHING + .. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None) .. note:: @@ -102,7 +104,7 @@ Core ... class C(object): ... x = attr.ib() >>> attr.fields(C).x - Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False) + Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) .. autofunction:: attr.make_class @@ -145,7 +147,9 @@ Exceptions ---------- .. autoexception:: attr.exceptions.PythonTooOldError +.. autoexception:: attr.exceptions.FrozenError .. autoexception:: attr.exceptions.FrozenInstanceError +.. autoexception:: attr.exceptions.FrozenAttributeError .. autoexception:: attr.exceptions.AttrsAttributeNotFoundError .. autoexception:: attr.exceptions.NotAnAttrsClassError .. autoexception:: attr.exceptions.DefaultAlreadySetError @@ -178,9 +182,9 @@ Helpers ... x = attr.ib() ... y = attr.ib() >>> attr.fields(C) - (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False)) + (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)) >>> attr.fields(C)[1] - Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False) + Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) >>> attr.fields(C).y is attr.fields(C)[1] True @@ -195,9 +199,9 @@ Helpers ... x = attr.ib() ... y = attr.ib() >>> attr.fields_dict(C) - {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False)} + {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)} >>> attr.fields_dict(C)['y'] - Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False) + Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) >>> attr.fields_dict(C)['y'] is attr.fields(C).y True @@ -516,6 +520,39 @@ Converters C(x='') +.. _api_setters: + +Setters +------- + +These are helpers that you can use together with `attr.s`'s and `attr.ib`'s ``on_setattr`` arguments. + +.. autofunction:: attr.setters.frozen +.. autofunction:: attr.setters.validate +.. autofunction:: attr.setters.convert +.. autofunction:: attr.setters.pipe +.. autodata:: attr.setters.DISABLE + + For example, only ``x`` is frozen here: + + .. doctest:: + + >>> @attr.s(on_setattr=attr.setters.frozen) + ... class C(object): + ... x = attr.ib() + ... y = attr.ib(on_setattr=attr.setters.DISABLE) + >>> c = C(1, 2) + >>> c.y = 3 + >>> c.y + 3 + >>> c.x = 4 + Traceback (most recent call last): + ... + attr.exceptions.FrozenAttributeError: () + + N.B. Please use `attr.s`'s *frozen* argument to freeze whole classes; it is more efficient. + + Deprecated APIs --------------- diff --git a/docs/extending.rst b/docs/extending.rst index 792fa775e..f3dc726e2 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -16,7 +16,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``: ... @attr.s ... class C(object): ... a = attr.ib() - (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False),) + (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None),) .. warning:: diff --git a/docs/how-does-it-work.rst b/docs/how-does-it-work.rst index 42d390ce3..8519c8119 100644 --- a/docs/how-does-it-work.rst +++ b/docs/how-does-it-work.rst @@ -52,7 +52,13 @@ This **static** approach was very much a design goal of ``attrs`` and what I str Immutability ------------ -In order to give you immutability, ``attrs`` will attach a ``__setattr__`` method to your class that raises a `attr.exceptions.FrozenInstanceError` whenever anyone tries to set an attribute. +In order to give you immutability, ``attrs`` will attach a ``__setattr__`` method to your class that raises an `attr.exceptions.FrozenInstanceError` whenever anyone tries to set an attribute. + +The same is true if you choose to freeze individual attributes using the `attr.setters.frozen` *on_setattr* hook -- except that the exception becomes `attr.exceptions.FrozenAttributeError`. + +Both errors subclass `attr.exceptions.FrozenError`. + +----- Depending on whether a class is a dict class or a slotted class, ``attrs`` uses a different technique to circumvent that limitation in the ``__init__`` method. @@ -88,7 +94,7 @@ This is (still) slower than a plain assignment: ........................................ Median +- std dev: 676 ns +- 16 ns -So on a standard notebook the difference is about 300 nanoseconds (1 second is 1,000,000,000 nanoseconds). +So on a laptop computer the difference is about 300 nanoseconds (1 second is 1,000,000,000 nanoseconds). It's certainly something you'll feel in a hot loop but shouldn't matter in normal code. Pick what's more important to you. diff --git a/setup.py b/setup.py index 246024331..1bb0475b4 100644 --- a/setup.py +++ b/setup.py @@ -50,9 +50,6 @@ EXTRAS_REQUIRE["dev"] = ( EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["docs"] + ["pre-commit"] ) -EXTRAS_REQUIRE["azure-pipelines"] = EXTRAS_REQUIRE["tests"] + [ - "pytest-azurepipelines" -] ############################################################################### diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 7c1e6434a..24327d0df 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -2,7 +2,7 @@ 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 ._make import ( @@ -63,6 +63,7 @@ "make_class", "s", "set_run_validators", + "setters", "validate", "validators", ] diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index ba8d7b3ad..376642b10 100644 --- a/src/attr/__init__.pyi +++ b/src/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 @@ -41,6 +42,10 @@ _ConverterType = Callable[[Any], _T] _FilterType = Callable[[Attribute[_T], _T], bool] _ReprType = Callable[[Any], str] _ReprArgType = Union[bool, _ReprType] +_OnSetAttrType = Callable[[Any, Any, Any], Any] +_OnSetAttrArgType = Union[ + _OnSetAttrType, List[_OnSetAttrType], setters.DisableType +] # 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. @@ -74,6 +79,7 @@ class Attribute(Generic[_T]): 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] @@ -113,6 +119,7 @@ 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. @@ -131,6 +138,7 @@ def attrib( kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form catches an explicit default argument. @@ -149,6 +157,7 @@ def attrib( 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 @@ -167,6 +176,7 @@ def attrib( kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... @overload def attrs( @@ -189,6 +199,7 @@ def attrs( order: Optional[bool] = ..., auto_detect: bool = ..., getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _C: ... @overload def attrs( @@ -211,6 +222,7 @@ def attrs( order: Optional[bool] = ..., auto_detect: bool = ..., getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Callable[[_C], _C]: ... # TODO: add support for returning NamedTuple from the mypy plugin @@ -242,6 +254,7 @@ def make_class( auto_exc: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> type: ... # _funcs -- diff --git a/src/attr/_make.py b/src/attr/_make.py index 6c039abc3..cdda2396d 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -9,7 +9,7 @@ from operator import itemgetter -from . import _config +from . import _config, setters from ._compat import ( PY2, isclass, @@ -29,7 +29,7 @@ # 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. @@ -200,6 +201,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.DISABLE` 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.DISABLE` .. versionadded:: 15.2.0 *convert* .. versionadded:: 16.3.0 *metadata* @@ -217,6 +224,7 @@ 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, True) @@ -238,6 +246,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 = chain(*converter) + return _CountingAttr( default=default, validator=validator, @@ -251,6 +269,7 @@ def attrib( kw_only=kw_only, eq=eq, order=order, + on_setattr=on_setattr, ) @@ -524,19 +543,20 @@ 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", ) def __init__( @@ -552,6 +572,7 @@ def __init__( cache_hash, is_exc, collect_by_mro, + on_setattr, ): attrs, base_attrs, base_map = _transform_attrs( cls, these, auto_attribs, kw_only, collect_by_mro, @@ -570,6 +591,7 @@ def __init__( 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._cls_dict["__attrs_attrs__"] = self._attrs @@ -774,6 +796,8 @@ def add_init(self): self._cache_hash, self._base_attr_map, self._is_exc, + self._on_setattr is not None + and self._on_setattr is not setters.DISABLE, ) ) @@ -799,6 +823,33 @@ def add_order(self): 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.DISABLE: + sa_attrs[a.name] = a, on_setattr + + if not sa_attrs: + return self + + 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["__setattr__"] = self._add_method_dunders(__setattr__) + + return self + def _add_method_dunders(self, method): """ Add __module__ and __qualname__ to a *method* if possible. @@ -816,8 +867,8 @@ def _add_method_dunders(self, method): 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 @@ -908,6 +959,7 @@ def attrs( auto_detect=False, collect_by_mro=False, getstate_setstate=None, + on_setattr=None, ): r""" A class decorator that adds `dunder @@ -1090,6 +1142,19 @@ def attrs( 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* .. versionadded:: 16.3.0 *str* @@ -1117,6 +1182,7 @@ def attrs( .. 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( @@ -1126,6 +1192,9 @@ def attrs( 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: @@ -1151,6 +1220,7 @@ def wrap(cls): cache_hash, is_exc, collect_by_mro, + on_setattr, ) if _determine_whether_to_implement( cls, repr, auto_detect, ("__repr__",) @@ -1169,6 +1239,8 @@ def wrap(cls): ): builder.add_order() + builder.add_setattr() + if ( hash_ is None and auto_detect is True @@ -1579,43 +1651,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. @@ -1700,8 +1735,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.DISABLE + ) 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. @@ -1712,85 +1873,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): - 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) + def fmt_setter(attr_name, value_var, has_on_setattr): 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 = [] @@ -1804,13 +1929,19 @@ def fmt_setter_with_converter(attr_name, value_var): 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.DISABLE 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) @@ -1818,16 +1949,18 @@ def fmt_setter_with_converter(attr_name, value_var): 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 @@ -1836,70 +1969,78 @@ def fmt_setter_with_converter(attr_name, value_var): 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 @@ -1908,13 +2049,18 @@ def fmt_setter_with_converter(attr_name, value_var): 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 @@ -1923,13 +2069,14 @@ def fmt_setter_with_converter(attr_name, value_var): 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__()") @@ -1992,6 +2139,7 @@ class Attribute(object): which is only syntactic sugar for ``default=Factory(...)``. .. versionadded:: 20.1.0 *inherited* + .. versionadded:: 20.1.0 *on_setattr* For the full version history of the fields, see `attr.ib`. """ @@ -2010,6 +2158,7 @@ class Attribute(object): "converter", "kw_only", "inherited", + "on_setattr", ) def __init__( @@ -2028,6 +2177,7 @@ def __init__( kw_only=False, eq=None, order=None, + on_setattr=None, ): eq, order = _determine_eq_order(cmp, eq, order, True) @@ -2056,6 +2206,7 @@ def __init__( 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() @@ -2185,6 +2336,7 @@ class _CountingAttr(object): "converter", "type", "kw_only", + "on_setattr", ) __attrs_attrs__ = tuple( Attribute( @@ -2199,6 +2351,7 @@ class _CountingAttr(object): eq=True, order=False, inherited=False, + on_setattr=None, ) for name in ( "counter", @@ -2208,6 +2361,7 @@ class _CountingAttr(object): "order", "hash", "init", + "on_setattr", ) ) + ( Attribute( @@ -2222,6 +2376,7 @@ class _CountingAttr(object): eq=True, order=False, inherited=False, + on_setattr=None, ), ) cls_counter = 0 @@ -2240,19 +2395,13 @@ def __init__( 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 @@ -2261,6 +2410,7 @@ def __init__( self.metadata = metadata self.type = type self.kw_only = kw_only + self.on_setattr = on_setattr def validator(self, meth): """ diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py index d3d8add93..fcd89106f 100644 --- a/src/attr/exceptions.py +++ b/src/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. diff --git a/src/attr/exceptions.pyi b/src/attr/exceptions.pyi index 736fde2e1..f2680118b 100644 --- a/src/attr/exceptions.pyi +++ b/src/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/src/attr/setters.py b/src/attr/setters.py new file mode 100644 index 000000000..1314e41b6 --- /dev/null +++ b/src/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 + + +DISABLE = 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/src/attr/setters.pyi b/src/attr/setters.pyi new file mode 100644 index 000000000..1b1d24522 --- /dev/null +++ b/src/attr/setters.pyi @@ -0,0 +1,12 @@ +from . import _OnSetAttrType, Attribute +from typing import TypeVar, Any, NewType, cast + +_T = TypeVar("_T") + +def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... +def frozen(instance: Any, attribute: Attribute, new_value: Any) -> Any: ... +def validate(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... +def convert(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... + +DisableType = NewType("DisableType", object) +DISABLE: DisableType diff --git a/src/attr/validators.py b/src/attr/validators.py index 99950e0e7..b9a73054e 100644 --- a/src/attr/validators.py +++ b/src/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() diff --git a/tests/test_dunders.py b/tests/test_dunders.py index a8c38a14d..391592ef6 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -68,6 +68,7 @@ def _add_init(cls, frozen): cache_hash=False, base_attr_map={}, is_exc=False, + has_global_on_setattr=False, ) return cls diff --git a/tests/test_functional.py b/tests/test_functional.py index b3017f5e8..6203adc5b 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -16,9 +16,11 @@ import attr +from attr import setters from attr._compat import PY2, TYPE from attr._make import NOTHING, Attribute -from attr.exceptions import FrozenInstanceError +from attr.exceptions import FrozenAttributeError, FrozenInstanceError +from attr.validators import instance_of, matches_re from .strategies import optional_bool @@ -681,3 +683,183 @@ class C(object): "2021-06-01. Please use `eq` and `order` instead." == w.message.args[0] ) + + +class TestSetAttr(object): + def test_change(self): + """ + The return value of a hook overwrites the value. But they are not run + on __init__. + """ + + def hook(*a, **kw): + return "hooked!" + + @attr.s + class Hooked(object): + x = attr.ib(on_setattr=hook) + y = attr.ib() + + h = Hooked("x", "y") + + assert "x" == h.x + assert "y" == h.y + + h.x = "xxx" + h.y = "yyy" + + assert "yyy" == h.y + assert "hooked!" == h.x + + def test_frozen_attribute(self): + """ + Frozen attributes raise FrozenAttributeError, others are not affected. + """ + + @attr.s + class PartiallyFrozen(object): + x = attr.ib(on_setattr=setters.frozen) + y = attr.ib() + + pf = PartiallyFrozen("x", "y") + + pf.y = "yyy" + + assert "yyy" == pf.y + + with pytest.raises(FrozenAttributeError): + pf.x = "xxx" + + assert "x" == pf.x + + @pytest.mark.parametrize( + "on_setattr", + [setters.validate, [setters.validate], setters.pipe(setters.validate)], + ) + def test_validator(self, on_setattr): + """ + Validators are run and they don't alter the value. + """ + + @attr.s(on_setattr=on_setattr) + class ValidatedAttribute(object): + x = attr.ib() + y = attr.ib(validator=[instance_of(str), matches_re("foo.*qux")]) + + va = ValidatedAttribute(42, "foobarqux") + + with pytest.raises(TypeError) as ei: + va.y = 42 + + assert "foobarqux" == va.y + + assert ei.value.args[0].startswith("'y' must be <") + + with pytest.raises(ValueError) as ei: + va.y = "quxbarfoo" + + assert ei.value.args[0].startswith("'y' must match regex '") + + assert "foobarqux" == va.y + + va.y = "foobazqux" + + assert "foobazqux" == va.y + + def test_pipe(self): + """ + Multiple hooks are possible, in that case the last return value is + used. They can be supplied using the pipe functions or by passing a + list to on_setattr. + """ + + s = [setters.convert, lambda _, __, nv: nv + 1] + + @attr.s + class Piped(object): + x1 = attr.ib(converter=int, on_setattr=setters.pipe(*s)) + x2 = attr.ib(converter=int, on_setattr=s) + + p = Piped("41", "22") + + assert 41 == p.x1 + assert 22 == p.x2 + + p.x1 = "41" + p.x2 = "22" + + assert 42 == p.x1 + assert 23 == p.x2 + + def test_make_class(self): + """ + on_setattr of make_class gets forwarded. + """ + C = attr.make_class("C", {"x": attr.ib()}, on_setattr=setters.frozen) + + c = C(1) + + with pytest.raises(FrozenAttributeError): + c.x = 2 + + def test_no_validator_no_converter(self): + """ + validate and convert tolerate missing validators and converters. + """ + + @attr.s(on_setattr=[setters.convert, setters.validate]) + class C(object): + x = attr.ib() + + c = C(1) + + c.x = 2 + + def test_validate_respects_run_validators_config(self): + """ + If run validators is off, validate doesn't run them. + """ + + @attr.s(on_setattr=setters.validate) + class C(object): + x = attr.ib(validator=attr.validators.instance_of(int)) + + c = C(1) + + attr.set_run_validators(False) + + c.x = "1" + + assert "1" == c.x + + attr.set_run_validators(True) + + with pytest.raises(TypeError) as ei: + c.x = "1" + + assert ei.value.args[0].startswith("'x' must be <") + + def test_frozen_on_setattr_class_is_caught(self): + """ + @attr.s(on_setattr=X, frozen=True) raises an ValueError. + """ + with pytest.raises(ValueError) as ei: + + @attr.s(frozen=True, on_setattr=setters.validate) + class C(object): + x = attr.ib() + + assert "Frozen classes can't use on_setattr." == ei.value.args[0] + + def test_frozen_on_setattr_attribute_is_caught(self): + """ + attr.ib(on_setattr=X) on a frozen class raises an ValueError. + """ + + with pytest.raises(ValueError) as ei: + + @attr.s(frozen=True) + class C(object): + x = attr.ib(on_setattr=setters.validate) + + assert "Frozen classes can't use on_setattr." == ei.value.args[0] diff --git a/tests/test_make.py b/tests/test_make.py index 8ccf191ee..9c853b32d 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -222,7 +222,7 @@ class C(object): "(name='y', default=NOTHING, validator=None, repr=True, " "eq=True, order=True, hash=None, init=True, " "metadata=mappingproxy({}), type=None, converter=None, " - "kw_only=False, inherited=False)", + "kw_only=False, inherited=False, on_setattr=None)", ) == e.value.args def test_kw_only(self): @@ -1425,7 +1425,18 @@ class C(object): pass b = _ClassBuilder( - C, None, True, True, False, False, False, False, False, False, True + C, + None, + True, + True, + False, + False, + False, + False, + False, + False, + True, + None, ) assert "<_ClassBuilder(cls=C)>" == repr(b) @@ -1439,7 +1450,18 @@ class C(object): x = attr.ib() b = _ClassBuilder( - C, None, True, True, False, False, False, False, False, False, True + C, + None, + True, + True, + False, + False, + False, + False, + False, + False, + True, + None, ) cls = ( @@ -1516,6 +1538,7 @@ class C(object): kw_only=False, cache_hash=False, collect_by_mro=True, + on_setattr=None, ) b._cls = {} # no __module__; no __qualname__ diff --git a/tests/typing_example.py b/tests/typing_example.py index ede74fc7b..73e39ecc7 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -185,6 +185,20 @@ class OrderFlags: b = attr.ib(eq=True, order=True) +# on_setattr hooks +@attr.s(on_setattr=attr.setters.validate) +class ValidatedSetter: + a = attr.ib() + b = attr.ib(on_setattr=attr.setters.DISABLE) + c = attr.ib(on_setattr=attr.setters.frozen) + d = attr.ib(on_setattr=[attr.setters.convert, attr.setters.validate]) + d = attr.ib( + on_setattr=attr.setters.pipe( + attr.setters.convert, attr.setters.validate + ) + ) + + # Auto-detect # XXX: needs support in mypy # @attr.s(auto_detect=True) diff --git a/tox.ini b/tox.ini index 51e863c86..bd54be41c 100644 --- a/tox.ini +++ b/tox.ini @@ -119,5 +119,5 @@ commands = towncrier --draft basepython = python3.8 deps = mypy commands = - mypy src/attr/__init__.pyi src/attr/_version_info.pyi src/attr/converters.pyi src/attr/exceptions.pyi src/attr/filters.pyi src/attr/validators.pyi + mypy src/attr/__init__.pyi src/attr/_version_info.pyi src/attr/converters.pyi src/attr/exceptions.pyi src/attr/filters.pyi src/attr/setters.pyi src/attr/validators.pyi mypy tests/typing_example.py From d657f4c73e99461ad6a2c7d8be18d7e0cc8f79c9 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 10 Jul 2020 07:58:40 +0200 Subject: [PATCH 2/9] Add PR newsfragment --- changelog.d/660.change.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/660.change.rst diff --git a/changelog.d/660.change.rst b/changelog.d/660.change.rst new file mode 100644 index 000000000..90d1a536c --- /dev/null +++ b/changelog.d/660.change.rst @@ -0,0 +1,5 @@ +It is now possible to specify hooks that are called whenever an attribute is set **after** a class has been instantiated. + +You can pass ``on_setattr`` both to ``@attr.s()`` to set the default for all attributes on a class, and to ``@attr.ib()`` to overwrite it for individual attributes. + +``attrs`` also comes with a new module ``attr.setters`` that brings helpers that run validators, converters, or allow to freeze a subset of attributes. From 5cdb32f10bf48f074d968d8556b18a1905241556 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 10 Jul 2020 07:59:04 +0200 Subject: [PATCH 3/9] Fix attr.s doc sig --- docs/api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.rst b/docs/api.rst index d89c3f3d5..cda75f337 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -18,7 +18,7 @@ Core .. autodata:: attr.NOTHING -.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None) +.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None) .. note:: From dd6dfde9c61b4646ac15252020be1e748bc44ee4 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 13 Jul 2020 14:25:44 +0200 Subject: [PATCH 4/9] Make _DisableType private --- src/attr/__init__.pyi | 2 +- src/attr/setters.pyi | 4 ++-- tox.ini | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 376642b10..70fe3e405 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -44,7 +44,7 @@ _ReprType = Callable[[Any], str] _ReprArgType = Union[bool, _ReprType] _OnSetAttrType = Callable[[Any, Any, Any], Any] _OnSetAttrArgType = Union[ - _OnSetAttrType, List[_OnSetAttrType], setters.DisableType + _OnSetAttrType, List[_OnSetAttrType], setters._DisableType ] # 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 diff --git a/src/attr/setters.pyi b/src/attr/setters.pyi index 1b1d24522..512f05fc2 100644 --- a/src/attr/setters.pyi +++ b/src/attr/setters.pyi @@ -8,5 +8,5 @@ def frozen(instance: Any, attribute: Attribute, new_value: Any) -> Any: ... def validate(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... def convert(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... -DisableType = NewType("DisableType", object) -DISABLE: DisableType +_DisableType = NewType("_DisableType", object) +DISABLE: _DisableType diff --git a/tox.ini b/tox.ini index bd54be41c..80ecf40b4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,4 @@ [pytest] -strict = true addopts = -ra testpaths = tests filterwarnings = From bde7e1ee15a1d4ede7455202aa6370490df67aad Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 14 Jul 2020 07:39:15 +0200 Subject: [PATCH 5/9] Mark setters.frozen as NoReturn --- src/attr/setters.pyi | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/attr/setters.pyi b/src/attr/setters.pyi index 512f05fc2..446c919c6 100644 --- a/src/attr/setters.pyi +++ b/src/attr/setters.pyi @@ -1,10 +1,12 @@ from . import _OnSetAttrType, Attribute -from typing import TypeVar, Any, NewType, cast +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 frozen(instance: Any, attribute: Attribute, new_value: Any) -> Any: ... def validate(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... def convert(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... From 10fba17698b86b9c078b7f3eeeef2bc89b34ef4a Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 20 Jul 2020 09:12:44 +0200 Subject: [PATCH 6/9] Rename setters.DISABLE to setters.NO_OP to clarify its purpose DISABLE sounds less purposeful and doesn't convey its meaning as well. --- docs/api.rst | 4 ++-- src/attr/_make.py | 12 ++++++------ src/attr/setters.py | 2 +- src/attr/setters.pyi | 4 ++-- tests/typing_example.py | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index cda75f337..5313d3813 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -531,7 +531,7 @@ These are helpers that you can use together with `attr.s`'s and `attr.ib`'s ``on .. autofunction:: attr.setters.validate .. autofunction:: attr.setters.convert .. autofunction:: attr.setters.pipe -.. autodata:: attr.setters.DISABLE +.. autodata:: attr.setters.NO_OP For example, only ``x`` is frozen here: @@ -540,7 +540,7 @@ These are helpers that you can use together with `attr.s`'s and `attr.ib`'s ``on >>> @attr.s(on_setattr=attr.setters.frozen) ... class C(object): ... x = attr.ib() - ... y = attr.ib(on_setattr=attr.setters.DISABLE) + ... y = attr.ib(on_setattr=attr.setters.NO_OP) >>> c = C(1, 2) >>> c.y = 3 >>> c.y diff --git a/src/attr/_make.py b/src/attr/_make.py index cdda2396d..a9851cd71 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -203,10 +203,10 @@ def attrib( 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.DISABLE` to run **no** `setattr` hooks for this + 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.DISABLE` + `attr.setters.NO_OP` .. versionadded:: 15.2.0 *convert* .. versionadded:: 16.3.0 *metadata* @@ -797,7 +797,7 @@ def add_init(self): self._base_attr_map, self._is_exc, self._on_setattr is not None - and self._on_setattr is not setters.DISABLE, + and self._on_setattr is not setters.NO_OP, ) ) @@ -830,7 +830,7 @@ def add_setattr(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.DISABLE: + if on_setattr and on_setattr is not setters.NO_OP: sa_attrs[a.name] = a, on_setattr if not sa_attrs: @@ -1765,7 +1765,7 @@ def _make_init( needs_cached_setattr = True elif ( - has_global_on_setattr and a.on_setattr is not setters.DISABLE + 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 @@ -1932,7 +1932,7 @@ def fmt_setter_with_converter( attr_name = a.name has_on_setattr = a.on_setattr is not None or ( - a.on_setattr is not setters.DISABLE and has_global_on_setattr + a.on_setattr is not setters.NO_OP and has_global_on_setattr ) arg_name = a.name.lstrip("_") diff --git a/src/attr/setters.py b/src/attr/setters.py index 1314e41b6..240014b3c 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -67,7 +67,7 @@ def convert(instance, attrib, new_value): return new_value -DISABLE = object() +NO_OP = object() """ Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. diff --git a/src/attr/setters.pyi b/src/attr/setters.pyi index 446c919c6..bde1865fb 100644 --- a/src/attr/setters.pyi +++ b/src/attr/setters.pyi @@ -10,5 +10,5 @@ def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... def validate(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... def convert(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... -_DisableType = NewType("_DisableType", object) -DISABLE: _DisableType +_NoOpType = NewType("_NoOpType", object) +NO_OP: _NoOpType diff --git a/tests/typing_example.py b/tests/typing_example.py index 73e39ecc7..01c061049 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -189,7 +189,7 @@ class OrderFlags: @attr.s(on_setattr=attr.setters.validate) class ValidatedSetter: a = attr.ib() - b = attr.ib(on_setattr=attr.setters.DISABLE) + b = attr.ib(on_setattr=attr.setters.NO_OP) c = attr.ib(on_setattr=attr.setters.frozen) d = attr.ib(on_setattr=[attr.setters.convert, attr.setters.validate]) d = attr.ib( From 0e5d87e91c4d7bd2020c46d31a76bd1f0aba7b93 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 20 Jul 2020 10:43:57 +0200 Subject: [PATCH 7/9] Fix type --- src/attr/__init__.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 70fe3e405..6f5cff6ef 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -44,7 +44,7 @@ _ReprType = Callable[[Any], str] _ReprArgType = Union[bool, _ReprType] _OnSetAttrType = Callable[[Any, Any, Any], Any] _OnSetAttrArgType = Union[ - _OnSetAttrType, List[_OnSetAttrType], setters._DisableType + _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 From 9784eab6c9d85277159eeefd9cecfd85a7e5b2bf Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 20 Jul 2020 11:20:51 +0200 Subject: [PATCH 8/9] Loosen up type for convert even further --- src/attr/setters.pyi | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/attr/setters.pyi b/src/attr/setters.pyi index bde1865fb..101a50f6b 100644 --- a/src/attr/setters.pyi +++ b/src/attr/setters.pyi @@ -7,8 +7,10 @@ def frozen( instance: Any, attribute: Attribute, new_value: Any ) -> NoReturn: ... def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... -def validate(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... -def convert(instance: Any, attribute: Attribute, new_value: _T) -> _T: ... +def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... + +# convert is allowed to return Any, because they cann be chained using pipe. +def convert(instance: Any, attribute: Attribute, new_value: Any) -> Any: ... _NoOpType = NewType("_NoOpType", object) NO_OP: _NoOpType From df20a627d775cbcbe06ecb04fea0fcc5bfe2d1c2 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 20 Jul 2020 11:34:38 +0200 Subject: [PATCH 9/9] Tighten type a tiny bit --- src/attr/__init__.pyi | 2 +- src/attr/setters.pyi | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 6f5cff6ef..7283b9b12 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -42,7 +42,7 @@ _ConverterType = Callable[[Any], _T] _FilterType = Callable[[Attribute[_T], _T], bool] _ReprType = Callable[[Any], str] _ReprArgType = Union[bool, _ReprType] -_OnSetAttrType = Callable[[Any, Any, Any], Any] +_OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any] _OnSetAttrArgType = Union[ _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType ] diff --git a/src/attr/setters.pyi b/src/attr/setters.pyi index 101a50f6b..13860c117 100644 --- a/src/attr/setters.pyi +++ b/src/attr/setters.pyi @@ -10,7 +10,9 @@ def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... # convert is allowed to return Any, because they cann be chained using pipe. -def convert(instance: Any, attribute: Attribute, new_value: Any) -> Any: ... +def convert( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> Any: ... _NoOpType = NewType("_NoOpType", object) NO_OP: _NoOpType