From 31580180a65adccd5958d4bbbf814febeb3994d3 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 22 Apr 2018 08:37:50 +0200 Subject: [PATCH 1/2] Add instance-level validators --- src/attr/_make.py | 33 +++++++++++++++++++++++++++------ tests/test_dark_magic.py | 18 ++++++++++++++++++ tests/test_make.py | 29 +++++++++++++++-------------- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 063c7c72a..ea90e6f26 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -399,15 +399,19 @@ class _ClassBuilder(object): __slots__ = ( "_cls", "_cls_dict", "_attrs", "_super_names", "_attr_names", "_slots", "_frozen", "_has_post_init", "_delete_attribs", "_super_attr_map", + "_inst_validator" ) - def __init__(self, cls, these, slots, frozen, auto_attribs): + def __init__( + self, cls, these, slots, frozen, auto_attribs, inst_validator + ): attrs, super_attrs, super_map = _transform_attrs( cls, these, auto_attribs ) self._cls = cls self._cls_dict = dict(cls.__dict__) if slots else {} + self._inst_validator = inst_validator self._attrs = attrs self._super_names = set(a.name for a in super_attrs) self._super_attr_map = super_map @@ -573,6 +577,7 @@ def add_init(self): self._frozen, self._slots, self._super_attr_map, + self._inst_validator, ) ) @@ -610,7 +615,8 @@ def _add_method_dunders(self, method): def attrs(maybe_cls=None, these=None, repr_ns=None, repr=True, cmp=True, hash=None, init=True, - slots=False, frozen=False, str=False, auto_attribs=False): + slots=False, frozen=False, str=False, auto_attribs=False, + validator=None): r""" A class decorator that adds `dunder `_\ -methods according to the @@ -707,6 +713,8 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, Attributes annotated as :data:`typing.ClassVar` are **ignored**. .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ + :param callable validator: Run after a new instance is fully initialized + with the instance as its only argument. .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* @@ -718,12 +726,15 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, .. versionchanged:: 18.1.0 If *these* is passed, no attributes are deleted from the class body. .. versionchanged:: 18.1.0 If *these* is ordered, the order is retained. + .. versionadded:: 18.1.0 *validator* """ def wrap(cls): if getattr(cls, "__class__", None) is None: raise TypeError("attrs only works with new-style classes.") - builder = _ClassBuilder(cls, these, slots, frozen, auto_attribs) + builder = _ClassBuilder( + cls, these, slots, frozen, auto_attribs, validator, + ) if repr is True: builder.add_repr(repr_ns) @@ -1020,7 +1031,9 @@ def _add_repr(cls, ns=None, attrs=None): return cls -def _make_init(attrs, post_init, frozen, slots, super_attr_map): +def _make_init( + attrs, post_init, frozen, slots, super_attr_map, inst_validator +): attrs = [ a for a in attrs @@ -1040,6 +1053,7 @@ def _make_init(attrs, post_init, frozen, slots, super_attr_map): slots, post_init, super_attr_map, + inst_validator, ) locs = {} bytecode = compile(script, unique_filename, "exec") @@ -1078,6 +1092,7 @@ def _add_init(cls, frozen): frozen, _is_slot_cls(cls), {}, + None, ) return cls @@ -1166,7 +1181,9 @@ def _is_slot_attr(a_name, super_attr_map): return a_name in super_attr_map and _is_slot_cls(super_attr_map[a_name]) -def _attrs_to_init_script(attrs, frozen, slots, post_init, super_attr_map): +def _attrs_to_init_script( + attrs, frozen, slots, post_init, super_attr_map, inst_validator +): """ Return a script of an initializer for *attrs* and a dict of globals. @@ -1355,7 +1372,8 @@ def fmt_setter_with_converter(attr_name, value_var): if a.init is True and a.converter is None and a.type is not None: annotations[arg_name] = a.type - if attrs_to_validate: # we can skip this if there are no validators. + # We can skip this if there are no validators. + if attrs_to_validate or inst_validator: names_for_globals["_config"] = _config lines.append("if _config._run_validators is True:") for a in attrs_to_validate: @@ -1365,6 +1383,9 @@ def fmt_setter_with_converter(attr_name, value_var): val_name, attr_name, a.name)) names_for_globals[val_name] = a.validator names_for_globals[attr_name] = a + if inst_validator: + names_for_globals["inst_validator"] = inst_validator + lines.append(" inst_validator(self)") if post_init: lines.append("self.__attrs_post_init__()") diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py index ca4c1c920..c200dd5da 100644 --- a/tests/test_dark_magic.py +++ b/tests/test_dark_magic.py @@ -413,3 +413,21 @@ class Sub(Base): with pytest.raises(FrozenInstanceError): i.b = "3" + + def test_inst_validator(self): + """ + Instance-level validators run. + """ + def v(i): + if i.x != 42: + raise ValueError + + @attr.s(validator=v) + class C(object): + x = attr.ib() + y = attr.ib() + + C(42, 42) + + with pytest.raises(ValueError): + C(23, 42) diff --git a/tests/test_make.py b/tests/test_make.py index 9e256f209..18c2f5131 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -667,6 +667,18 @@ def test_make_class_ordered(self): assert "C(a=1, b=2)" == repr(C()) + def test_repr_str(self): + """ + Trying to add a `__str__` without having a `__repr__` raises a + ValueError. + """ + with pytest.raises(ValueError) as ei: + make_class("C", {}, repr=False, str=True) + + assert ( + "__str__ can only be generated if a __repr__ exists.", + ) == ei.value.args + class TestFields(object): """ @@ -1068,18 +1080,6 @@ class TestClassBuilder(object): """ Tests for `_ClassBuilder`. """ - def test_repr_str(self): - """ - Trying to add a `__str__` without having a `__repr__` raises a - ValueError. - """ - with pytest.raises(ValueError) as ei: - make_class("C", {}, repr=False, str=True) - - assert ( - "__str__ can only be generated if a __repr__ exists.", - ) == ei.value.args - def test_repr(self): """ repr of builder itself makes sense. @@ -1087,7 +1087,7 @@ def test_repr(self): class C(object): pass - b = _ClassBuilder(C, None, True, True, False) + b = _ClassBuilder(C, None, True, True, False, None) assert "<_ClassBuilder(cls=C)>" == repr(b) @@ -1098,7 +1098,7 @@ def test_returns_self(self): class C(object): x = attr.ib() - b = _ClassBuilder(C, None, True, True, False) + b = _ClassBuilder(C, None, True, True, False, None) cls = b.add_cmp().add_hash().add_init().add_repr("ns").add_str() \ .build_class() @@ -1137,6 +1137,7 @@ class C(object): b = _ClassBuilder( C, these=None, slots=False, frozen=False, auto_attribs=False, + inst_validator=None, ) b._cls = {} # no __module__; no __qualname__ From a5c05df0f859cfcc2d7984b217f1df613bb0eedb Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 22 Apr 2018 08:58:29 +0200 Subject: [PATCH 2/2] Add newsfragment, clarify when instance validator runs --- changelog.d/372.change.rst | 1 + src/attr/_make.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelog.d/372.change.rst diff --git a/changelog.d/372.change.rst b/changelog.d/372.change.rst new file mode 100644 index 000000000..1b5b44e66 --- /dev/null +++ b/changelog.d/372.change.rst @@ -0,0 +1 @@ +``@attr.s`` now also has a *validator* argument that takes a callable and is run with the fully initialized instance. diff --git a/src/attr/_make.py b/src/attr/_make.py index ea90e6f26..9fff7cb02 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -714,7 +714,8 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ :param callable validator: Run after a new instance is fully initialized - with the instance as its only argument. + with the instance as its only argument. *validator* runs *after* + the attribute validators. .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen*